commit 534bb94eeab45181748feccfd735b81f8b162794 Author: wehub-resource-sync Date: Mon Jul 13 12:44:22 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d18057c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.github +.venv +.vscode +.data +.temp +web/.next +web/node_modules +web/.env diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..514445f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,39 @@ +name: 漏洞反馈 +description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network +title: "[Bug]: " +labels: ["bug?"] +body: + - type: input + attributes: + label: 运行环境 + description: LangBot 版本、操作系统、系统架构、**Python版本**、**主机地理位置** + placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker + validations: + required: true + - type: dropdown + attributes: + label: 部署版本 + description: 请选择您使用的 LangBot 部署版本。 + options: + - 社区版 + - 云服务 + validations: + required: true + - type: textarea + attributes: + label: 异常情况 + description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。** + validations: + required: true + - type: textarea + attributes: + label: 复现步骤 + description: 提供越多信息,我们会越快解决问题,建议多提供配置截图;**如果涉及 Dify、n8n、Langflow 等外部平台,请提供应用的导出文件(如 Dify 应用的 DSL),我们将更快回复您。** + validations: + required: false + - type: textarea + attributes: + label: 启用的插件 + description: 有些情况可能和插件功能有关,建议提供插件启用情况。 + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/bug-report_en.yml b/.github/ISSUE_TEMPLATE/bug-report_en.yml new file mode 100644 index 0000000..d2111e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report_en.yml @@ -0,0 +1,39 @@ +name: Bug report +description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network +title: "[Bug]: " +labels: ["bug?"] +body: + - type: input + attributes: + label: Runtime environment + description: LangBot version, operating system, system architecture, **Python version**, **host location** + placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker" + validations: + required: true + - type: dropdown + attributes: + label: Deployment version + description: Please select the LangBot deployment version you are using. + options: + - Community Edition + - Cloud Service + validations: + required: true + - type: textarea + attributes: + label: Exception + description: Describe the exception in detail, what happened and when it happened. **Please include log information.** + validations: + required: true + - type: textarea + attributes: + label: Reproduction steps + description: How to reproduce this problem, the more detailed the better; the more information you provide, the faster we will solve the problem. + validations: + required: false + - type: textarea + attributes: + label: Enabled plugins + description: Some cases may be related to plugin functionality, so please provide the plugin enablement status. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..5aa8ca5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,21 @@ +name: 需求建议 +title: "[Feature]: " +labels: [] +description: "【供中文用户】新功能或现有功能优化请使用这个模板;不符合类别的issue将被直接关闭" +body: + - type: dropdown + attributes: + label: 这是一个? + description: 新功能建议还是现有功能优化 + options: + - 新功能 + - 现有功能优化 + validations: + required: true + - type: textarea + attributes: + label: 详细描述 + description: 详细描述,越详细越好 + validations: + required: true + diff --git a/.github/ISSUE_TEMPLATE/feature-request_en.yml b/.github/ISSUE_TEMPLATE/feature-request_en.yml new file mode 100644 index 0000000..5590162 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request_en.yml @@ -0,0 +1,21 @@ +name: Feature request +title: "[Feature]: " +labels: [] +description: "New features or existing feature improvements should use this template; issues that do not match will be closed directly" +body: + - type: dropdown + attributes: + label: This is a? + description: New feature request or existing feature improvement + options: + - New feature + - Existing feature improvement + validations: + required: true + - type: textarea + attributes: + label: Detailed description + description: Detailed description, the more detailed the better + validations: + required: true + diff --git a/.github/ISSUE_TEMPLATE/submit-plugin.yml b/.github/ISSUE_TEMPLATE/submit-plugin.yml new file mode 100644 index 0000000..bd7529f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/submit-plugin.yml @@ -0,0 +1,24 @@ +name: 提交新插件 +title: "[Plugin]: 请求登记新插件" +labels: ["独立插件"] +description: "【供中文用户】本模板供且仅供提交新插件使用" +body: + - type: input + attributes: + label: 插件名称 + description: 填写插件的名称 + validations: + required: true + - type: textarea + attributes: + label: 插件代码库地址 + description: 仅支持 Github + validations: + required: true + - type: textarea + attributes: + label: 插件简介 + description: 插件的简介 + validations: + required: true + diff --git a/.github/ISSUE_TEMPLATE/submit-plugin_en.yml b/.github/ISSUE_TEMPLATE/submit-plugin_en.yml new file mode 100644 index 0000000..51b9b61 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/submit-plugin_en.yml @@ -0,0 +1,24 @@ +name: Submit a new plugin +title: "[Plugin]: Request to register a new plugin" +labels: ["Independent Plugin"] +description: "This template is only for submitting new plugins" +body: + - type: input + attributes: + label: Plugin name + description: Fill in the name of the plugin + validations: + required: true + - type: textarea + attributes: + label: Plugin code repository address + description: Only support Github + validations: + required: true + - type: textarea + attributes: + label: Plugin description + description: The description of the plugin + validations: + required: true + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..53c5849 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + allow: + - dependency-name: "openai" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..add2118 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,33 @@ +## 概述 / Overview + +> 请在此部分填写你实现/解决/优化的内容: +> Summary of what you implemented/solved/optimized: +> + +### 更改前后对比截图 / Screenshots + +> 请在此部分粘贴更改前后对比截图(可以是界面截图、控制台输出、对话截图等): +> Please paste the screenshots of changes before and after here (can be interface screenshots, console output, conversation screenshots, etc.): +> +> 修改前 / Before: +> +> 修改后 / After: +> + +## 检查清单 / Checklist + +### PR 作者完成 / For PR author + +*请在方括号间写`x`以打勾 / Please tick the box with `x`* + +- [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)? +- [ ] 我已签署或将在机器人提示后签署 [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。 / I have signed, or will sign when prompted by the bot, the [CLA](https://github.com/langbot-app/LangBot/blob/master/CLA.md). +- [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer? +- [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected. + +### 项目维护者完成 / For project maintainer + +- [ ] 相关 issues 链接了吗? / Have you linked the related issues? +- [ ] 配置项写好了吗?迁移写好了吗?生效了吗? / Have you written the configuration items? Have you written the migration? Has it taken effect? +- [ ] 依赖加到 pyproject.toml 和 core/bootutils/deps.py 了吗 / Have you added the dependencies to pyproject.toml and core/bootutils/deps.py? +- [ ] 文档编写了吗? / Have you written the documentation? \ No newline at end of file diff --git a/.github/workflows/build-dev-image.yaml b/.github/workflows/build-dev-image.yaml new file mode 100644 index 0000000..062a72d --- /dev/null +++ b/.github/workflows/build-dev-image.yaml @@ -0,0 +1,29 @@ +name: Build Dev Image + +on: + push: + workflow_dispatch: + +jobs: + build-dev-image: + runs-on: ubuntu-latest + # 如果是tag则跳过 + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Generate Tag + id: generate_tag + run: | + # 获取分支名称,把/替换为- + echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g' + echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g') + - name: Login to Registry + run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }} + - name: Build Docker Image + run: | + docker buildx create --name mybuilder --use + docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml new file mode 100644 index 0000000..4575c4f --- /dev/null +++ b/.github/workflows/build-docker-image.yml @@ -0,0 +1,47 @@ +name: Build Docker Image +on: + ## 发布release的时候会自动构建 + release: + types: [published] +jobs: + publish-docker-image: + runs-on: ubuntu-latest + name: Build image + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: judge has env GITHUB_REF # 如果没有GITHUB_REF环境变量,则把github.ref变量赋值给GITHUB_REF + run: | + if [ -z "$GITHUB_REF" ]; then + export GITHUB_REF=${{ github.ref }} + echo $GITHUB_REF + fi + - name: Check version + id: check_version + run: | + echo $GITHUB_REF + # 如果是tag,则去掉refs/tags/前缀 + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "It's a tag" + echo $GITHUB_REF + echo $GITHUB_REF | awk -F '/' '{print $3}' + echo ::set-output name=version::$(echo $GITHUB_REF | awk -F '/' '{print $3}') + else + echo "It's not a tag" + echo $GITHUB_REF + echo ::set-output name=version::${GITHUB_REF} + fi + - name: Login to Registry + run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }} + - name: Create Buildx + run: docker buildx create --name mybuilder --use + - name: Build for Release # only relase, exlude pre-release + if: ${{ github.event.release.prerelease == false }} + run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push + - name: Build for Pre-release # no update for latest tag + if: ${{ github.event.release.prerelease == true }} + run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push \ No newline at end of file diff --git a/.github/workflows/build-release-artifacts.yaml b/.github/workflows/build-release-artifacts.yaml new file mode 100644 index 0000000..ed36fae --- /dev/null +++ b/.github/workflows/build-release-artifacts.yaml @@ -0,0 +1,62 @@ +name: Build Release Artifacts + +on: + workflow_dispatch: + ## 发布release的时候会自动构建 + release: + types: [published] + +jobs: + build-artifacts: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Check version + id: check_version + run: | + echo $GITHUB_REF + # 如果是tag,则去掉refs/tags/前缀 + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "It's a tag" + echo $GITHUB_REF + echo $GITHUB_REF | awk -F '/' '{print $3}' + echo ::set-output name=version::$(echo $GITHUB_REF | awk -F '/' '{print $3}') + else + echo "It's not a tag" + echo $GITHUB_REF + echo ::set-output name=version::${GITHUB_REF} + fi + + - name: Make Temp Directory + run: | + mkdir -p /tmp/langbot_build_web + cp -r . /tmp/langbot_build_web + - name: Setup Node + uses: actions/setup-node@v2 + with: + node-version: '22' + - name: Build Web + run: | + cd /tmp/langbot_build_web/web + npm install + npx vite build + - name: Package Output + run: | + cp -r /tmp/langbot_build_web/web/dist ./web + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: langbot-${{ steps.check_version.outputs.version }}-all + path: . + + - name: Upload To Release + env: + GH_TOKEN: ${{ secrets.RELEASE_UPLOAD_GITHUB_TOKEN }} + run: | + # 本目录下所有文件打包成zip + zip -r langbot-${{ steps.check_version.outputs.version }}-all.zip . + gh release upload ${{ github.event.release.tag_name }} langbot-${{ steps.check_version.outputs.version }}-all.zip diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml new file mode 100644 index 0000000..688a5c6 --- /dev/null +++ b/.github/workflows/check-i18n.yml @@ -0,0 +1,25 @@ +name: Check i18n Keys + +on: + push: + branches: + - main + - master + +jobs: + check-i18n: + name: Check i18n Key Consistency + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Check i18n keys against en-US reference + run: node web/scripts/check-i18n.mjs diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..996e5a6 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,41 @@ +name: "CLA Assistant" +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened, closed, synchronize, reopened] + +permissions: + actions: write # re-run the failed CLA check after signing + contents: read # signatures are stored in the remote langbot-app/cla repo + pull-requests: write # post guidance comments, lock PR after merge + statuses: write # set the commit status + +jobs: + CLAAssistant: + runs-on: ubuntu-latest + steps: + - name: "CLA Assistant" + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + # Upstream repo was archived in 2026-03; pin to the v2.6.1 commit SHA. + uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # repo-scope PAT with write access to langbot-app/cla + PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }} + with: + path-to-document: 'https://github.com/langbot-app/LangBot/blob/master/CLA.md' + remote-organization-name: 'langbot-app' + remote-repository-name: 'cla' + path-to-signatures: 'signatures/version1/cla.json' + branch: 'main' + allowlist: 'dependabot[bot],github-actions[bot],devin-ai-integration[bot],Copilot,renovate[bot],bot*' + custom-notsigned-prcomment: | + Thank you for your contribution! :heart: Before we can merge this pull request, we need you to sign the [LangBot Contributor License Agreement (CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md). You keep full copyright of your code — the CLA grants us a license to use and distribute your contribution. Signing takes 10 seconds and covers all repositories in this organization, permanently. + + 感谢您的贡献!合并前请阅读并签署[贡献者许可协议(CLA)](https://github.com/langbot-app/LangBot/blob/master/CLA.md)。您保留代码的全部版权,签署仅需回复下方指定内容,一次签署对本组织全部仓库永久有效。 + custom-allsigned-prcomment: 'All contributors have signed the CLA. :white_check_mark: 所有贡献者均已签署 CLA。' + lock-pullrequest-aftermerge: true + # SECURITY: this workflow runs on pull_request_target (it holds secrets and has + # write access to the base repository). NEVER add an actions/checkout step that + # checks out the PR's code here. diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 0000000..0265e04 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,46 @@ +name: Frontend Tests + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'web/**' + - '.github/workflows/frontend-tests.yml' + push: + branches: + - master + - develop + paths: + - 'web/**' + - '.github/workflows/frontend-tests.yml' + +jobs: + playwright-smoke: + name: Playwright Smoke + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '25' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8.9.2 + + - name: Install dependencies + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + working-directory: web + run: pnpm exec playwright install --with-deps chromium + + - name: Run Playwright smoke tests + working-directory: web + run: pnpm test:e2e diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..f2baae7 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,60 @@ +name: Lint + +on: + push: + branches: + - main + - master + - dev + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + ruff: + name: Ruff Lint & Format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --dev + + - name: Run ruff check + run: uv run ruff check src/langbot/ tests/ --output-format=concise + + - name: Run ruff format + run: uv run ruff format src --check + + frontend: + name: Frontend Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '25' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + working-directory: web + run: pnpm install + + - name: Run lint + working-directory: web + run: pnpm lint diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 0000000..363acc0 --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,46 @@ +name: Build and Publish to PyPI + +on: + workflow_dispatch: + release: + types: [published] + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Required for trusted publishing to PyPI + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Build frontend + run: | + cd web + npm install -g pnpm + pnpm install + pnpm build + mkdir -p ../src/langbot/web/dist + cp -r dist ../src/langbot/web/ + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v6 + with: + version: "latest" + + - name: Build package + run: | + uv build + + - name: Publish to PyPI + run: | + uv publish --token ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..aaee595 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,193 @@ +name: Unit Tests + +on: + pull_request: + types: [opened, ready_for_review, synchronize] + paths: + - 'src/langbot/**' + - 'tests/**' + - '.github/workflows/run-tests.yml' + - 'pyproject.toml' + - 'uv.lock' + - 'run_tests.sh' + - 'scripts/test-*.sh' + push: + branches: + - master + - develop + - 'feat/**' + # No path filter on push: every push to the branches above runs the + # full unit-test suite. feat/** branches in particular must be tested + # on every push (they accumulate large changes before a PR exists). + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.11', '3.12', '3.13'] + fail-fast: false + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --dev + + - name: Run unit + smoke tests + run: uv run pytest tests/unit_tests/ tests/smoke/ -q --tb=short + + - name: Test Summary + if: always() + run: | + echo "## Unit Tests Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Python Version: ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY + echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + integration: + name: Fast Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --dev + + - name: Run fast integration tests + run: uv run pytest tests/integration/ -m "not slow" -q --tb=short + + - name: Integration Test Summary + if: always() + run: | + echo "## Integration Tests Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + e2e: + name: E2E Startup Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --dev + + - name: Run E2E startup tests + run: uv run pytest tests/e2e -q --tb=short + + - name: E2E Test Summary + if: always() + run: | + echo "## E2E Startup Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + box-integration: + name: Box Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --dev + + - name: Check Docker runtime + run: docker info + + - name: Run Box integration tests + run: uv run pytest tests/integration_tests -q --tb=short + + - name: Box Integration Test Summary + if: always() + run: | + echo "## Box Integration Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + coverage: + name: Coverage Gate + runs-on: ubuntu-latest + needs: [test, integration] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --dev + + - name: Run coverage (unit + smoke) + run: | + uv run pytest tests/unit_tests/ tests/smoke/ \ + --cov=langbot \ + --cov-report=xml \ + --cov-report=term-missing \ + --cov-fail-under=18 \ + -q --tb=short + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + flags: unit-tests + name: coverage-report + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Coverage Summary + if: always() + run: | + echo "## Coverage Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Threshold: 18%" >> $GITHUB_STEP_SUMMARY + echo "Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/test-dev-image.yaml b/.github/workflows/test-dev-image.yaml new file mode 100644 index 0000000..1f2c0aa --- /dev/null +++ b/.github/workflows/test-dev-image.yaml @@ -0,0 +1,108 @@ +name: Test Dev Image + +on: + workflow_run: + workflows: ["Build Dev Image"] + types: + - completed + branches: + - master + +jobs: + test-dev-image: + runs-on: ubuntu-latest + # Only run if the build workflow succeeded + if: ${{ github.event.workflow_run.conclusion == 'success' }} + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Update Docker Compose to use master tag + working-directory: ./docker + run: | + # Replace 'latest' with 'master' tag for testing the dev image + sed -i 's/rockchin\/langbot:latest/rockchin\/langbot:master/g' docker-compose.yaml + echo "Updated docker-compose.yaml to use master tag:" + cat docker-compose.yaml + + - name: Start Docker Compose + working-directory: ./docker + run: docker compose up -d + + - name: Wait and Test API + run: | + # Function to test API endpoint + test_api() { + echo "Testing API endpoint..." + response=$(curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" http://localhost:5300/api/v1/system/info 2>&1) + curl_exit_code=$? + + if [ $curl_exit_code -ne 0 ]; then + echo "Curl failed with exit code: $curl_exit_code" + echo "Error: $response" + return 1 + fi + + http_code=$(echo "$response" | tail -n 1) + response_body=$(echo "$response" | head -n -1) + + if [ "$http_code" = "200" ]; then + echo "API is healthy! Response code: $http_code" + echo "Response: $response_body" + return 0 + else + echo "API returned non-200 response: $http_code" + echo "Response body: $response_body" + return 1 + fi + } + + # Wait 30 seconds before first attempt + echo "Waiting 30 seconds for services to start..." + sleep 30 + + # Try up to 3 times with 30-second intervals + max_attempts=3 + attempt=1 + + while [ $attempt -le $max_attempts ]; do + echo "Attempt $attempt of $max_attempts" + + if test_api; then + echo "Success! API is responding correctly." + exit 0 + fi + + if [ $attempt -lt $max_attempts ]; then + echo "Retrying in 30 seconds..." + sleep 30 + fi + + attempt=$((attempt + 1)) + done + + # All attempts failed + echo "Failed to get healthy response after $max_attempts attempts" + exit 1 + + - name: Show Container Logs on Failure + if: failure() + working-directory: ./docker + run: | + echo "=== Docker Compose Status ===" + docker compose ps + echo "" + echo "=== LangBot Logs ===" + docker compose logs langbot + echo "" + echo "=== Plugin Runtime Logs ===" + docker compose logs langbot_plugin_runtime + + - name: Cleanup + if: always() + working-directory: ./docker + run: docker compose down diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml new file mode 100644 index 0000000..2b911da --- /dev/null +++ b/.github/workflows/test-migrations.yml @@ -0,0 +1,78 @@ +name: Test Migrations + +on: + push: + branches: + - main + - master + - dev + paths: + - 'src/langbot/pkg/persistence/**' + - 'src/langbot/pkg/entity/persistence/**' + - 'tests/integration/persistence/**' + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'src/langbot/pkg/persistence/**' + - 'src/langbot/pkg/entity/persistence/**' + - 'tests/integration/persistence/**' + +jobs: + test-migrations-sqlite: + name: Migrations (SQLite) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --dev + + - name: Run SQLite migration tests + run: uv run pytest tests/integration/persistence/test_migrations.py -q --tb=short + + test-migrations-postgres: + name: Migrations (PostgreSQL) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: langbot + POSTGRES_PASSWORD: langbot + POSTGRES_DB: langbot_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U langbot" + --health-interval=5s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --dev + + - name: Run PostgreSQL migration tests + env: + TEST_POSTGRES_URL: postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test + run: uv run pytest tests/integration/persistence/test_migrations_postgres.py -q --tb=short \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97a64ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +/config.py +.idea/ +__pycache__/ +database.db +langbot.log +/banlist.py +/plugins/ +!/plugins/__init__.py +/revcfg.py +prompts/ +logs/ +sensitive.json +temp/ +current_tag +scenario/ +!scenario/default-template.json +override.json +cookies.json +data/labels/announcement_saved.json +cmdpriv.json +tips.py +venv* +bin/ +.vscode +/test_* +venv/ +hugchat.json +qcapi +claude.json +bard.json +/*yaml +!.pre-commit-config.yaml +!components.yaml +!/docker-compose.yaml +data/labels/instance_id.json +.DS_Store +/data +botpy.log* +/poc +/libs/wecom_api/test.py +/venv +test.py +/web_ui +.venv/ +/test +plugins.bak +coverage.xml +.coverage +src/langbot/web/ +testsdk/ +.qa/ + +# Build artifacts +/dist +/build +*.egg-info + +# Next.js build cache (legacy) +web/.next/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a23a26f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,37 @@ +{ + "mcpServers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + }, + "sequential-thinking": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"], + "env": {} + }, + "github": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + }, + "fetch": { + "type": "stdio", + "command": "uvx", + "args": ["mcp-server-fetch"], + "env": {} + }, + "playwright": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@playwright/mcp@latest"], + "env": {} + } + } +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..32f4cde --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.11.7 + hooks: + # Run the linter of backend. + - id: ruff + args: [--fix] + # Run the formatter of backend. + - id: ruff-format + + - repo: local + hooks: + - id: prettier + name: prettier + entry: npx --prefix web prettier --write --ignore-unknown + language: system + types_or: [javascript, jsx, ts, tsx, css, scss] + + - id: lint-staged + name: lint-staged + entry: cd web && pnpm lint-staged + language: system + types: [javascript, jsx, ts, tsx] + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a886d2e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,115 @@ +# AGENTS.md + +This file guides code agents working in the LangBot main repository. `CLAUDE.md` is a symlink to this file. + +Read `ARCHITECTURE.md` before non-trivial backend, frontend, runtime, plugin, Box, MCP, persistence, or cross-repo SDK changes. This file is the working checklist; `ARCHITECTURE.md` is the system map. + +## Quick Facts + +- Python backend: `>=3.11,<4.0`, dependencies managed by `uv`. +- Frontend: `web/` is Vite + React Router 7 + shadcn/ui + Tailwind, managed by `pnpm`. +- Backend framework: Quart served by Hypercorn on `api.port`, default `5300`. +- Frontend dev server: `web/` on `3000`, with `VITE_API_BASE_URL` pointing at the backend. +- Plugin/Box/runtime contracts live in sibling repo `langbot-plugin-sdk`, pinned as `langbot-plugin` in `pyproject.toml`. + +## Essential Commands + +```bash +uv sync --dev +uv run main.py +uv run pre-commit install + +cd web +pnpm install +pnpm dev +pnpm build +``` + +Useful focused tests: + +```bash +uv run pytest tests/unit_tests -q +uv run pytest tests/integration -q +uv run pytest tests/integration/persistence -q +uv run pytest tests/manual/mcp_smoke.py + +cd web +pnpm lint +pnpm test:e2e +``` + +Run the narrowest useful test first, then broader checks when confidence is needed. + +## Where to Look + +- Architecture map: `ARCHITECTURE.md`. +- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config. +- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime. +- API-key auth: `docs/API_KEY_AUTH.md`. +- Box deep-dive notes: `docs/review/box-architecture.md` and related files. +- In-repo skills: `skills/` is the single source of truth for LangBot agent skills. +- SDK repo: `../langbot-plugin-sdk/` when changing shared entities, plugin APIs, action protocol, `lbp rt`, or `lbp box`. + +## Cross-Repo SDK Work + +When changing SDK contracts used by LangBot: + +```bash +# from langbot-plugin-sdk, with LangBot's .venv active +uv pip install . + +# from LangBot, preserve the locally installed SDK +uv run --no-sync main.py +``` + +For standalone runtime debugging: + +```bash +# in langbot-plugin-sdk +uv run --no-sync lbp rt +uv run --no-sync lbp box + +# in LangBot +uv run --no-sync main.py --standalone-runtime +uv run --no-sync main.py --standalone-box +``` + +Config keys to verify in `data/config.yaml` / `src/langbot/templates/config.yaml`: + +- Plugin runtime: `plugin.runtime_ws_url`, default Docker host `langbot_plugin_runtime:5400/control/ws`. +- Box runtime: `box.enabled`, `box.backend`, `box.runtime.endpoint`, Docker host `langbot_box:5410`. +- API/MCP auth: `api.global_api_key`. + +## Change Rules + +- HTTP API changes that should be agent-accessible must update the matching MCP tool in `src/langbot/pkg/api/mcp/server.py` and the relevant skill under `skills/` in the same pass. +- New schema changes use Alembic under `src/langbot/pkg/persistence/alembic/versions/`; do not add legacy `dbmXXX` migrations. +- New platform behavior belongs in platform adapters only for platform translation; pipeline/business logic belongs in `pkg/pipeline/` or services. +- User-facing strings must support i18n (`en_US`, `zh_Hans`; include `ja_JP` where the repo already does). +- Code comments and docstrings must be English. +- Keep compatibility and security in mind; LangBot is used in both self-hosted/community and toB deployments. +- Commit message format: `(): `. + +## Runtime Pitfalls + +- Local stdio Plugin Runtime disconnects do not auto-reconnect; restart LangBot if that path breaks. +- Orphan runtime processes on `5400`/`5401` commonly break plugin debugging. +- Use `uv run --no-sync` after locally installing the SDK, or `uv` may restore the pinned package. +- A false Box “no backend” often means Docker is running but the current user lacks Docker socket permission. +- Do not confuse external MCP servers LangBot connects to (`pkg/provider/tools/loaders/mcp.py`) with LangBot's own `/mcp` server (`pkg/api/mcp/`). +- `CLAUDE.md` is a symlink to this file; edit `AGENTS.md`, not the symlink. + +## Principles + +- Keep it simple, stupid. +- Entities should not be multiplied unnecessarily. +- 八荣八耻 + + 以瞎猜接口为耻,以认真查询为荣。 + 以模糊执行为耻,以寻求确认为荣。 + 以臆想业务为耻,以人类确认为荣。 + 以创造接口为耻,以复用现有为荣。 + 以跳过验证为耻,以主动测试为荣。 + 以破坏架构为耻,以遵循规范为荣。 + 以假装理解为耻,以诚实无知为荣。 + 以盲目修改为耻,以谨慎重构为荣。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..ded90e7 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,250 @@ +# Architecture + +This document is a map of LangBot's moving parts. It is intentionally more stable than a feature guide and more concrete than the README: when you need to change behavior, start here, then follow the file references into the code. + +For agent-specific working rules, see `AGENTS.md`. For plugin-runtime and Box-runtime implementation details, also read the sibling SDK repo: [`langbot-plugin-sdk`](https://github.com/langbot-app/langbot-plugin-sdk). + +## What LangBot Is + +LangBot is an open-source platform for building production IM bots backed by LLMs, agents, RAG, plugins, MCP tools, and a web management panel. + +At runtime, one LangBot process owns: + +- a Quart/Hypercorn HTTP service and the built web UI on `:5300`; +- messaging-platform adapters such as Discord, Telegram, Slack, WeChat, QQ, WeCom, Lark, DingTalk, KOOK, LINE, Satori, Matrix, and HTTP/WebSocket bots; +- a pipeline engine that turns inbound platform messages into LLM/tool/plugin work and replies; +- persistence, storage, vector database, telemetry, monitoring, and configuration managers; +- bridges to the Plugin Runtime and Box Runtime provided by `langbot-plugin-sdk`; +- an MCP server at `/mcp` exposing a curated agent-facing subset of the service layer. + +## Repository Boundary + +LangBot is not a single-repo system. + +- `LangBot/` is the main product: backend, web UI, platform adapters, pipeline engine, HTTP API, MCP server, RAG, persistence, skills integration, and the bridge code that talks to runtimes. +- `langbot-plugin-sdk/` is published as `langbot-plugin` and pinned in `LangBot/pyproject.toml`. It contains plugin developer APIs, shared entities, `lbp`, the Plugin Runtime (`lbp rt`), and the Box Runtime (`lbp box`). +- Plugins import SDK APIs from `langbot_plugin.*`; the LangBot main process imports the same package for shared entities and runtime protocols. + +This split matters. If a change modifies SDK entities, component APIs, action protocols, `lbp rt`, or `lbp box`, verify the sibling SDK repo and install the local SDK into LangBot's virtualenv when testing cross-repo behavior. + +## Startup Path + +The process entrypoint is small and layered: + +1. `main.py` delegates to `langbot.__main__.main()`. +2. `src/langbot/__main__.py` parses `--standalone-runtime`, `--standalone-box`, and `--debug`, checks dependencies, generates missing config/data files, and calls `pkg.core.boot.main()`. +3. `pkg/core/boot.py` executes startup stages in order: `LoadConfigStage`, `GenKeysStage`, `SetupLoggerStage`, `BuildAppStage`, `ShowNotesStage`. +4. `BuildAppStage` constructs the `Application` object by wiring managers, services, runtime connectors, and controllers. +5. `Application.run()` starts the platform manager, query controller, HTTP controller, telemetry/cleanup loops, and plugin initialization. + +The central runtime object is `pkg/core/app.py::Application`. It is a service locator for long-lived managers. That is not elegant, but it is the current architectural center; most subsystems receive `ap: Application` and collaborate through it. + +## Top-Level Layout + +```text +LangBot/ +├── main.py # Entrypoint shim +├── pyproject.toml # Python package, deps, pinned langbot-plugin +├── src/langbot/ +│ ├── __main__.py # CLI entrypoint and boot handoff +│ ├── pkg/ +│ │ ├── core/ # Application, boot stages, task manager +│ │ ├── api/ # HTTP API + MCP server mount +│ │ ├── platform/ # IM adapters and runtime bot manager +│ │ ├── pipeline/ # Message routing and pipeline stages +│ │ ├── provider/ # LLM runners, model manager, tools +│ │ ├── plugin/ # LangBot-side Plugin Runtime connector/handler +│ │ ├── box/ # LangBot-side Box service/connector +│ │ ├── skill/ # Skill metadata/activation integration +│ │ ├── rag/ , vector/ # Knowledge-base and vector DB integration +│ │ ├── persistence/ # SQLAlchemy/SQLModel, Alembic, legacy migrations +│ │ ├── storage/ # Local/S3 file storage abstraction +│ │ └── config/, entity/, utils/, telemetry/, survey/ +│ ├── libs/ # Vendored third-party platform SDKs +│ └── templates/ # Default config and component metadata +├── web/ # Vite + React Router + shadcn/ui + Tailwind SPA +├── docker/ # Deployment manifests +├── skills/ # In-repo agent skills, single source of truth +└── tests/ # Unit/integration/e2e/manual tests +``` + +## The Runtime Graph + +The most useful mental model is this graph: + +```text +Platform adapter + → RuntimeBot + → MessageAggregator + → QueryPool + → Controller + → RuntimePipeline + → PipelineStage chain + → RequestRunner / ToolManager / PluginRuntimeConnector / BoxService + → response via adapter +``` + +The HTTP and MCP surfaces are parallel entrypoints into the same service layer: + +```text +HTTP client / Web UI + → Quart route group + → api/http/service/* + → Application managers / persistence / runtime connectors + +MCP client + → /mcp mount + → api/mcp/server.py tools + → the same service layer directly +``` + +## Message Flow + +Inbound platform messages enter through adapter-specific SDK callbacks. The common path is: + +1. A platform adapter under `pkg/platform/sources/` converts platform-specific events into SDK message/event entities. +2. `RuntimeBot` in `pkg/platform/botmgr.py` applies pipeline routing rules and either discards the message, pushes it to webhooks, or sends it to the message aggregator. +3. `MessageAggregator` batches/normalizes messages before adding a `Query` to `QueryPool`. +4. `Controller` in `pkg/pipeline/controller.py` selects queries subject to global pipeline concurrency and per-session concurrency. +5. `RuntimePipeline` in `pkg/pipeline/pipelinemgr.py` runs configured pipeline stages using a responsibility-chain style executor that supports generator stages. +6. The chat stage emits plugin events, calls a configured `RequestRunner`, handles streaming/non-streaming responses, records telemetry, and appends conversation history. +7. Output stages send text, cards, chunks, files, or error notices back through the original platform adapter. + +Pipeline components are registered by decorators and package import side effects. When adding a new stage, loader, runner, or adapter, check the corresponding preregistration mechanism instead of inventing a second registry. + +## Platform Layer + +Platform code lives under `pkg/platform/`. + +- `botmgr.py` owns runtime bots, routing rules, event logging, webhook pushing, and adapter lifecycle. +- `sources/` contains adapter implementations. Each adapter subclasses `langbot_plugin.api.definition.abstract.platform.adapter.AbstractMessagePlatformAdapter` from the SDK. +- Platform entities such as `MessageChain`, `Image`, `At`, `Voice`, and events come from `langbot-plugin-sdk`, not from this repo. + +The platform layer should translate between external platform APIs and LangBot's shared message/event model. It should not contain LLM-provider logic or pipeline business logic. + +## Pipeline Layer + +Pipeline code lives under `pkg/pipeline/`. + +Important pieces: + +- `pool.py::QueryPool` stores pending queries and cached in-flight queries for plugin backward-compatible calls. +- `controller.py::Controller` schedules query processing and enforces concurrency. +- `pipelinemgr.py::RuntimePipeline` materializes database pipeline config into a runtime stage chain. +- `process/handlers/chat.py::ChatMessageHandler` is the main LLM conversation handler. +- Stage families include response rules, banned sessions, content filters, preprocessors, rate limits, message truncation, long text handling, response-back, command handling, and wrappers. + +Pipelines are configuration-driven. Prefer adding a stage or extending an existing stage family over hard-coding behavior in platform adapters. + +## Provider, RAG, and Tools + +Provider code lives under `pkg/provider/`. + +- `modelmgr/` manages configured model providers and requesters. +- `runners/` implements request runners such as the local agent runner and external workflow integrations. +- `tools/toolmgr.py` aggregates tools from native tools, plugin tools, external MCP servers, and skill-authoring tools. +- `tools/loaders/mcp.py` is the MCP client side: external MCP servers that LangBot connects to for agent tools. +- RAG lives across `pkg/rag/`, `pkg/vector/`, model services, and plugin KnowledgeEngine actions. + +Do not confuse LangBot's MCP client side with LangBot's own MCP server at `/mcp`; they are different surfaces. + +## Plugin System + +The plugin system crosses the repo boundary. + +In this repo: + +- `pkg/plugin/connector.py` connects LangBot to the Plugin Runtime over stdio or WebSocket. +- `pkg/plugin/handler.py` exposes LangBot actions to the runtime and calls runtime actions for plugin operations. +- `pkg/provider/tools/loaders/plugin.py` exposes plugin Tool components to LLM runners. +- Pipeline handlers emit SDK events such as normal-message events and prompt-processing events. + +In `langbot-plugin-sdk`: + +- `src/langbot_plugin/api/` defines `BasePlugin`, component base classes, message/event entities, contexts, proxies, and manifests. +- `src/langbot_plugin/runtime/` implements `lbp rt`, plugin discovery, dependency installation, process launching, and control/debug connections. +- `src/langbot_plugin/entities/io/` defines the action protocol shared by LangBot, runtime, and plugin processes. + +The Plugin Runtime supports stdio and WebSocket control transports. Direct local LangBot runs usually spawn the runtime over stdio. Containerized/standalone deployments connect over WebSocket using `plugin.runtime_ws_url` and `--standalone-runtime`. + +## Box Runtime and Skills + +Box is the sandbox subsystem used by native agent tools, stdio MCP servers, skill authoring, and managed processes. + +In this repo: + +- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, skill CRUD, status, reconnects, quotas, mounts, and sandbox profiles. +- `pkg/box/connector.py` connects to the Box Runtime over stdio, Windows subprocess+WebSocket, or remote WebSocket. +- `pkg/provider/tools/loaders/native.py`, `mcp_stdio.py`, and skill loaders depend on Box availability. +- `pkg/skill/manager.py` loads skills from the Box runtime, falling back to local `data/skills` when needed. + +In `langbot-plugin-sdk`: + +- `src/langbot_plugin/box/server.py` implements `lbp box` and the WebSocket endpoints on `:5410`. +- `src/langbot_plugin/box/runtime.py` owns sandbox sessions and managed processes. +- `backend.py`, `nsjail_backend.py`, and `e2b_backend.py` implement sandbox backends. +- `skill_store.py` manages skill packages from the Box side. + +Important config keys live under `box:` in `src/langbot/templates/config.yaml`: `box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. Start LangBot with `--standalone-box` when connecting to an externally launched Box runtime. + +## HTTP API, Web UI, and MCP Server + +`pkg/api/http/controller/main.py` builds a Quart app, registers route groups, serves the built SPA, and wraps the ASGI app with the MCP dispatcher. + +- HTTP route groups live under `pkg/api/http/controller/groups/`. +- Service-layer logic lives under `pkg/api/http/service/`. +- The built web UI is served from the frontend build path with SPA fallback. +- The MCP server lives under `pkg/api/mcp/` and is mounted at `/mcp`. + +The MCP server intentionally exposes a curated subset of the API. Tools call service classes directly rather than making HTTP requests back into LangBot. + +Maintenance rule: when adding, removing, or changing an HTTP endpoint that should be agent-accessible, update the matching MCP tool and the relevant in-repo skill under `skills/` in the same pass. + +## Persistence and Configuration + +Persistence is centered on `pkg/persistence/mgr.py`. + +- SQLite is the default database; PostgreSQL is supported. +- Models live under `pkg/entity/persistence/`. +- Fresh schemas are created from metadata, then legacy migrations run up to the frozen 3.x baseline, then Alembic migrations run to head. +- New schema changes should use Alembic under `pkg/persistence/alembic/versions/`; do not extend the frozen legacy migration chain. + +Configuration starts from `src/langbot/templates/config.yaml` and is generated into `data/config.yaml` on first run. Most long-lived managers read from `ap.instance_config.data`. + +## Frontend + +The frontend lives in `web/` and is a Vite SPA using React Router 7, shadcn/ui, Tailwind CSS, and pnpm. It is not Next.js, despite some historical filenames. + +In development, `pnpm dev` serves the UI on `:3000` and reads `VITE_API_BASE_URL` to call the backend on `:5300`. In production, the built frontend is packaged into the Python distribution and served by the backend. + +Keep frontend API behavior aligned with `pkg/api/http/service/` and route groups. User-facing strings must go through the existing i18n setup. + +## Agent-Facing Surfaces + +LangBot is deliberately agent-friendly. The agent-facing surfaces are part of the architecture, not extra docs. + +- `skills/` is the single source of truth for in-repo skills. +- `pkg/api/mcp/server.py` exposes the LangBot MCP server at `/mcp`. +- `api.global_api_key` authenticates API/MCP access without a browser login. +- `AGENTS.md` and `ARCHITECTURE.md` tell coding agents how the repo works. + +When one of these changes, update the others if the behavior or contract changed. API, MCP tools, and skills are one system; drift is a bug. + +## Where to Change Things + +- New HTTP API: add/adjust a service in `pkg/api/http/service/`, a route group in `pkg/api/http/controller/groups/`, tests, and MCP/skills if agent-accessible. +- New platform adapter: add a `pkg/platform/sources/*` adapter, component metadata/templates as needed, i18n, docs, and tests/smoke coverage. +- New pipeline behavior: add or extend a pipeline stage family under `pkg/pipeline/`; avoid putting pipeline rules in adapters. +- New LLM provider/requester: work under `pkg/provider/modelmgr/` and related service/UI surfaces. +- New LLM tool source: extend `pkg/provider/tools/loaders/` and `ToolManager` intentionally. +- New plugin component/API/protocol: change `langbot-plugin-sdk` first or in lockstep, then update LangBot bridge code. +- New Box capability: change both `pkg/box/` and `langbot-plugin-sdk/src/langbot_plugin/box/`, plus config and tests. +- New database schema: add an Alembic migration, not a legacy `dbmXXX` migration. + +## Design Biases + +- Keep platform translation, pipeline orchestration, provider execution, and runtime protocols separate. +- Reuse existing registries and service layers instead of adding parallel paths. +- Prefer small, explicit agent surfaces over exposing every internal API. +- Treat cross-repo contracts with the SDK as public interfaces. +- Test behavior at the narrowest useful layer first, then add integration/e2e coverage for runtime or platform changes. diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..ac04798 --- /dev/null +++ b/CLA.md @@ -0,0 +1,107 @@ +# LangBot Individual Contributor License Agreement (v1.0) + +Thank you for your interest in contributing to LangBot (the "Project"), stewarded by Beijing Langbo Intelligent Technology Co., Ltd. (北京浪波智能科技有限公司) ("We" or "Us"). + +This Individual Contributor License Agreement ("Agreement") documents the rights granted by contributors to Us. By signing this Agreement (see Section 9), You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the Project. Except for the licenses granted herein to Us and recipients of software distributed by Us, You reserve all right, title, and interest in and to Your Contributions. + +## 1. Definitions + +"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Us. + +"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to Us for inclusion in, or documentation of, any of the products or repositories owned or managed by Us (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." + +## 2. Grant of Copyright License + +Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. For clarity, this includes the right for Us to distribute Your Contributions, alone or as part of the Work, under the terms of any license, including without limitation open source licenses and commercial or proprietary licenses. + +## 3. Grant of Patent License + +Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that Your Contribution, or the Work to which You have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. + +## 4. Authority; Employer + +You represent that You are legally entitled to grant the above licenses. If Your employer(s) has rights to intellectual property that You create that includes Your Contributions, You represent that You have received permission to make Contributions on behalf of that employer, that Your employer has waived such rights for Your Contributions to Us, or that Your employer has executed a separate Corporate Contributor License Agreement with Us. + +## 5. Original Creation; Disclosure + +You represent that each of Your Contributions is Your original creation (see Section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which You are personally aware and which are associated with any part of Your Contributions. + +## 6. No Obligation of Support; Disclaimer + +You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +## 7. Third-Party Works + +Should You wish to submit work that is not Your original creation, You may submit it to Us separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which You are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". + +## 8. Notification + +You agree to notify Us of any facts or circumstances of which You become aware that would make these representations inaccurate in any respect. + +## 9. Electronic Signature + +This Agreement is accepted and signed electronically: posting a comment containing the exact phrase designated by Us (currently "I have read the CLA Document and I hereby sign the CLA") from Your GitHub account on a pull request in the Project's repositories constitutes Your binding electronic signature to this Agreement. You represent that the GitHub account used to sign belongs to You and that You are of legal age to form a binding contract. Your signature covers Your present and future Contributions to all repositories owned or managed by Us, until and unless You notify Us in writing that You withdraw from this Agreement for future Contributions (licenses already granted are irrevocable). + +## 10. Our Commitment + +We commit that the Project's main repository will continue to make an open source version of the Work publicly available. + +## 11. Miscellaneous + +This Agreement is the entire agreement between You and Us regarding Your Contributions and supersedes any prior agreements on this subject. If any provision is held unenforceable, the remaining provisions remain in effect. This Agreement is executed in English; the Chinese translation below is provided for reference only, and the English version shall prevail in case of any discrepancy. + +--- + +# LangBot 个人贡献者许可协议(v1.0)中文参考译文 + +> 本译文仅供参考,如与英文版有任何歧义,以英文版为准。 + +感谢您有意为 LangBot(下称"本项目")作出贡献。本项目由北京浪波智能科技有限公司(下称"我方")运营管理。 + +本《个人贡献者许可协议》(下称"本协议")旨在记录贡献者授予我方的各项权利。您一经签署本协议(见第 9 条),即接受并同意以下条款与条件,适用于您向本项目提交的现在及未来的全部贡献。除本协议授予我方及我方分发软件之接收者的许可外,您保留对您的贡献的全部权利、所有权和利益。 + +## 1. 定义 + +"您"指与我方订立本协议的版权所有人,或经版权所有人授权的法律实体。 + +"贡献"指您有意提交给我方、用于纳入我方拥有或管理的任何产品或代码仓库(下称"作品")或其文档的任何原创作品,包括对既有作品的修改或增补。就本定义而言,"提交"指以任何电子、口头或书面形式向我方或我方代表发送的通信,包括但不限于在由我方或代表我方管理的电子邮件列表、源代码管理系统和问题跟踪系统中,为讨论和改进作品而进行的通信;但您以显著方式标注或以书面形式声明为"非贡献"(Not a Contribution)的通信除外。 + +## 2. 版权许可的授予 + +在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的版权许可,以复制您的贡献、基于其创作衍生作品、公开展示、公开表演、再许可以及分发您的贡献及上述衍生作品。为明确起见,上述许可包括我方有权以任何许可条款(包括但不限于开源许可证以及商业或专有许可证)单独或作为作品的一部分分发您的贡献。 + +## 3. 专利许可的授予 + +在遵守本协议条款与条件的前提下,您特此授予我方及我方分发软件之接收者一项永久的、全球范围的、非独占的、免费的、免版税的、不可撤销的(本条所述情形除外)专利许可,以制造、委托制造、使用、许诺销售、销售、进口及以其他方式转让作品;该许可仅适用于您可许可的、且因您的贡献本身或您的贡献与其所提交之作品的结合而必然受到侵犯的专利权利要求。如任何实体对您或任何其他实体提起专利诉讼(包括诉讼中的交叉请求或反诉),主张您的贡献或您所贡献的作品构成直接或帮助性专利侵权,则依据本协议就该贡献或作品授予该实体的任何专利许可,自该诉讼提起之日起终止。 + +## 4. 权利能力与雇主 + +您声明您在法律上有权授予上述许可。如您的雇主对您创作的、包含您的贡献在内的知识产权享有权利,您声明:您已获得该雇主代表其作出贡献的许可,或该雇主已就您向我方的贡献放弃上述权利,或该雇主已与我方另行签署《企业贡献者许可协议》。 + +## 5. 原创性声明与披露义务 + +您声明您的每项贡献均为您的原创作品(代表第三方提交的情形见第 7 条)。您声明您提交的贡献中已完整披露您本人知悉的、与您的贡献任何部分相关的任何第三方许可或其他限制(包括但不限于相关专利和商标)的全部细节。 + +## 6. 无支持义务;免责声明 + +您无义务为您的贡献提供支持,除非您自愿提供。您可以免费提供支持、收费提供支持或不提供支持。除非适用法律要求或另有书面约定,您的贡献按"现状"(AS IS)提供,不附带任何明示或默示的保证或条件,包括但不限于关于权属、不侵权、适销性或特定用途适用性的任何保证或条件。 + +## 7. 第三方作品 + +如您希望提交非您原创的作品,您可以将其与任何贡献分开单独提交给我方,并完整说明其来源以及您本人知悉的任何许可或其他限制(包括但不限于相关专利、商标和许可协议)的全部细节,同时以显著方式将该作品标注为"代表第三方提交:[此处注明第三方名称]"。 + +## 8. 通知义务 + +如您知悉任何事实或情况将导致上述声明在任何方面不准确,您同意通知我方。 + +## 9. 电子签署 + +本协议以电子方式接受并签署:您通过您的 GitHub 账号,在本项目代码仓库的拉取请求(pull request)中发表包含我方指定语句(现为 "I have read the CLA Document and I hereby sign the CLA")的评论,即构成您对本协议具有约束力的电子签名。您声明用于签署的 GitHub 账号归您本人所有,且您已达到订立有约束力合同的法定年龄。您的签署覆盖您对我方拥有或管理的全部代码仓库的现在及未来的贡献,直至您以书面形式通知我方就未来贡献退出本协议为止(已授予的许可不可撤销)。 + +## 10. 我方承诺 + +我方承诺本项目主仓库将持续公开提供作品的开源版本。 + +## 11. 其他 + +本协议构成您与我方之间就您的贡献达成的完整协议,并取代双方先前就此主题达成的任何协议。如本协议任何条款被认定为不可执行,其余条款仍然有效。本协议以英文签署,中文译文仅供参考,如有歧义以英文版为准。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a3fc22f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +## 参与项目 + +欢迎为此项目贡献代码或其他支持,以使您的点子或众人期待的功能成为现实,助力社区成长。 + +### 贡献形式 + +- 提交PR,解决issues中提到的bug或期待的功能 +- 提交PR,实现您设想的功能(请先提出issue与项目维护者沟通) +- 为本项目在其他社交平台撰写文章、制作视频等 +- 为本项目的衍生项目作出贡献,或开发插件增加功能 + +### 沟通语言规范 + +- 在 PR 和 Commit Message 中请使用全英文 +- 对于中文用户,issue 中可以使用中文 + +### 贡献者许可协议(CLA) + +为了保护项目和每一位贡献者,我们要求所有代码贡献者签署[贡献者许可协议(CLA)](./CLA.md)。这是 Apache、Google、Grafana 等主流开源项目的标准做法:您保留自己代码的全部版权,仅授予项目使用、分发您贡献的许可。 + +签署只需 10 秒:首次提交 PR 时,机器人会自动评论提示,按提示回复一句话即完成签署,此后对本组织所有仓库永久有效。历史贡献不受影响。 + +
+ +## Guidelines + +### Contribution + +- Submit PRs to solve bugs or features in the issues +- Submit PRs to implement your ideas (Please create an issue first and communicate with the project maintainer) +- Write articles or make videos about this project on other social platforms +- Contribute to the development of derivative projects, or develop plugins to add features + +### Spoken Language + +- Use English in PRs and Commit Messages +- For English users, you can use English in issues + +### Contributor License Agreement (CLA) + +To protect the project and every contributor, we require all code contributors to sign our [Contributor License Agreement](./CLA.md). This is standard practice in major open source projects such as Apache, Google, and Grafana: you keep full copyright of your code — the CLA only grants us a license to use and distribute your contribution. + +Signing takes 10 seconds: when you open your first PR, a bot will guide you to reply with a single comment. One signature covers all repositories in this organization, permanently. Past contributions are not affected. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..99fce8f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +FROM node:22-alpine AS node + +WORKDIR /app + +COPY web ./web + +RUN cd web && npm install && npx vite build + +# Build nsjail from source so the image ships a self-contained sandbox backend +# that needs no host Docker socket. Pinned to a release tag for reproducibility. +# Multi-stage keeps the compile toolchain (bison/flex/protobuf-dev/libnl-dev) +# out of the final image; only the nsjail binary and its small runtime libs +# (libprotobuf, libnl-route-3) are carried over. +FROM python:3.12.7-slim AS nsjail-build + +ARG NSJAIL_VERSION=3.6 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates git build-essential \ + autoconf bison flex libtool pkg-config \ + protobuf-compiler libprotobuf-dev libnl-route-3-dev \ + && git clone --depth 1 --branch "${NSJAIL_VERSION}" https://github.com/google/nsjail.git /nsjail \ + && make -C /nsjail \ + && install -m 0755 /nsjail/nsjail /usr/local/bin/nsjail \ + && rm -rf /var/lib/apt/lists/* + +FROM python:3.12.7-slim + +WORKDIR /app + +COPY . . + +COPY --from=node /app/web/dist ./web/dist + +# nsjail binary built in the dedicated stage above. Self-contained sandbox +# backend; lets the Box runtime isolate code without a host Docker socket. +COPY --from=nsjail-build /usr/local/bin/nsjail /usr/local/bin/nsjail + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \ + # nsjail runtime libraries (the build toolchain stays in the nsjail-build + # stage; only these shared libs are needed to execute the binary). + && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 \ + # Install the Docker CLI (client only) so the optional langbot_box + # service can drive the mounted host Docker socket and create sandbox + # containers. The same image powers langbot / plugin_runtime / box; only + # box uses the client. Arch-aware via dpkg so multi-arch builds work. + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends docker-ce-cli \ + # Install Node.js LTS so the sandbox (nsjail/Docker box) can run npx-based + # stdio MCP servers. node/npx land in /usr/bin, which is on the nsjail + # read-only mount whitelist (_READONLY_SYSTEM_MOUNTS), so they are bound + # into the sandbox chroot automatically. Without node, any npx-launched + # MCP server exits with return_code=127 (command not found). + && curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh \ + && bash /tmp/nodesource_setup.sh \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -f /tmp/nodesource_setup.sh \ + && python -m pip install --no-cache-dir uv \ + && uv sync \ + && apt-get purge -y --auto-remove curl gnupg \ + && rm -rf /var/lib/apt/lists/* \ + && touch /.dockerenv + +CMD [ "uv", "run", "--no-sync", "main.py" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c057a76 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +# LangBot Makefile +# Quick developer commands + +.PHONY: test test-quick test-integration-fast test-coverage test-all-local lint + +# Run all tests (full suite with coverage) +test: + bash run_tests.sh + +# Quick self-test for developers (lint + unit + smoke, no real credentials needed) +test-quick: + bash scripts/test-quick.sh + +# Fast integration tests (SQLite/API/Pipeline, no external services) +test-integration-fast: + bash scripts/test-integration-fast.sh + +# Coverage gate (all tests, enforces minimum threshold) +test-coverage: + bash scripts/test-coverage.sh + +# Full local quality gate (quick + integration + coverage) +test-all-local: + bash scripts/test-quick.sh + bash scripts/test-integration-fast.sh + bash scripts/test-coverage.sh + +# Run linting only +lint: + ruff check src/langbot/ tests/ + ruff format --check src/langbot/ tests/ + +# Fix linting issues +lint-fix: + ruff check --fix src/langbot/ tests/ + ruff format src/langbot/ tests/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f56ce7f --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +

+ +LangBot + + +

+ +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt + +

Production-grade platform for building agentic IM bots.

+

Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.

+ +English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) +python +[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) + +Website | +Features | +Docs | +API | +Cloud | +Plugin Market | +Roadmap + +
+ +

+ +--- + +## What is LangBot? + +LangBot is an **open-source, production-grade platform** for building AI-powered instant messaging bots. It connects Large Language Models (LLMs) to any chat platform, enabling you to create intelligent agents that can converse, execute tasks, and integrate with your existing workflows. + +

+LangBot web management dashboard — real-time monitoring of message volume, model calls, success rate and active sessions +

+ +### Key Capabilities + +- **AI Conversations & Agents** — Multi-turn dialogues, tool calling, multi-modal support, streaming output. Built-in RAG (knowledge base) with deep integration to [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com). +- **Universal IM Platform Support** — One codebase for Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. +- **Production-Ready** — Access control, rate limiting, sensitive word filtering, comprehensive monitoring, and exception handling. Trusted by enterprises. +- **Plugin Ecosystem** — Hundreds of plugins, event-driven architecture, component extensions, and [MCP protocol](https://modelcontextprotocol.io/) support. +- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required. +- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling. + +[→ Learn more about all features](https://link.langbot.app/en/docs/features) + +📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). + +--- + +## 😎 Stay Updated + +Click the Star and Watch buttons in the top-right corner of the repository to get the latest updates. + +![star gif](https://langbot.app/star.gif) + +## Quick Start + +### ☁️ LangBot Cloud (Recommended) + +**[LangBot Cloud](https://space.langbot.app/cloud)** — Zero deployment, ready to use. + +### One-Line Launch + +```bash +uvx langbot +``` + +> Requires [uv](https://docs.astral.sh/uv/getting-started/installation/). Visit http://localhost:5300 — done. + +### Docker Compose + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker +docker compose --profile all up -d +``` + +### One-Click Cloud Deploy + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) + +**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) + +--- + +## Supported Platforms + +| Platform | Status | Notes | +|----------|--------|-------| +| Discord | ✅ | Official | +| Telegram | ✅ | Official | +| Slack | ✅ | Official | +| LINE | ✅ | Official | +| QQ | ✅ | Personal & Official API (Channel, DM, Group) | +| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot | +| WeChat | ✅ | Personal & Official Account | +| Lark | ✅ | Official | +| DingTalk | ✅ | Official | +| KOOK | ✅ | Official | +| Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Supports multiple bridged platforms such as Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, and more | + +--- + +## Supported LLMs & Integrations + +| Provider | Type | Status | +| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ | +| [OpenAI](https://platform.openai.com/) | LLM | ✅ | +| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | +| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | +| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | +| [xAI](https://x.ai/) | LLM | ✅ | +| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | +| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | +| [Ollama](https://ollama.com/) | Local LLM | ✅ | +| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ | +| [Dify](https://dify.ai) | LLMOps | ✅ | +| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ | +| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ | +| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ | +| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ | +| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ | +| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ | +| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ | +| [接口 AI](https://jiekou.ai/) | Gateway | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ | + +[→ View all integrations](https://link.langbot.app/en/docs/features) + +--- + +## Why LangBot? + +| Use Case | How LangBot Helps | +| --------------------------- | ------------------------------------------------------------------------------------------ | +| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base | +| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes | +| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction | +| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard | + +--- + +## Built for AI Agents 🤖 + +LangBot is **agent-friendly by design** — your coding agents (Claude Code, Codex, Copilot, Cursor, …) can operate, extend, and deploy LangBot with first-class support: + +- **MCP Server** — LangBot exposes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) endpoint at `/mcp`, mirroring the HTTP API so an agent can manage bots, pipelines, plugins, and models programmatically. Authenticate with the same API key (set a global key in `config.yaml` or use a per-user key) — no login flow required. Configure it in the Web panel's **API & MCP** tab. +- **In-repo Skills** — The [`skills/`](skills/) directory is the **single source of truth** for working with LangBot: plugin development, core development, end-to-end testing, deployment, and operating the LangBot / LangBot Space MCP servers. Point your agent at this directory and it knows how to build. +- **AGENTS.md** — Every repo ships an [`AGENTS.md`](AGENTS.md) (symlinked to `CLAUDE.md`) describing architecture, conventions, and the rule that API changes must keep the MCP server and skills in sync. +- **`llms.txt`** — Machine-readable project context for LLMs is published on the website. + +> **Cloud / Marketplace:** [LangBot Space](https://space.langbot.app) also exposes an MCP server so agents can search and inspect the plugin / MCP / skill marketplace, authenticated with a Personal Access Token. + +--- + +## Live Demo + +**Try it now:** https://demo.langbot.dev/ + +- Email: `demo@langbot.app` +- Password: `langbot123456` + +_Note: Public demo environment. Do not enter sensitive information._ + +--- + +## Community + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) + +- [Discord Community](https://discord.gg/wdNEHETs87) + +--- + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) + +--- + +## Contributors + +Thanks to all [contributors](https://github.com/langbot-app/LangBot/graphs/contributors) who have helped make LangBot better: + + + + diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..bf423b1 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`langbot-app/LangBot` +- 原始仓库:https://github.com/langbot-app/LangBot +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000..ab0d5f2 --- /dev/null +++ b/README_CN.md @@ -0,0 +1,224 @@ +

+ +LangBot + + +

+ +Featured|HelloGitHub + +

生产级 AI 即时通信机器人开发平台。

+

快速构建、调试和部署 AI 机器人到微信、QQ、飞书、Slack、Discord、Telegram 等平台。

+ +[English](README.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) +[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/IrlV8QFacU) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) +python +[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) +[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) + +官网 | +特性 | +文档 | +API | +Cloud | +扩展市场 | +路线图 + +
+ +

+ +--- + +LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。 + +

+LangBot Web 管理面板仪表盘 — 实时监控消息量、模型调用、成功率与活跃会话 +

+ +### 核心能力 + +- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。 +- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。 +- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。 +- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。 +- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。 +- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。 + +[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features) + +📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 + +--- + +## 😎 保持更新 + +点击[仓库首页](https://github.com/langbot-app/LangBot)右上角 Star 和 Watch 按钮,获取最新动态。 + +![star gif](https://langbot.app/star.gif) + +## 快速开始 + +### ☁️ LangBot Cloud(推荐) + +**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,开箱即用。 + +### 一键启动 + +```bash +uvx langbot +``` + +> 需要安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)。访问 http://localhost:5300 即可使用。 + +### Docker Compose + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker +docker compose --profile all up -d +``` + +### 一键云部署 + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) + +**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) + +--- + +## 支持的平台 + +| 平台 | 状态 | 备注 | +|------|------|------| +| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) | +| 微信 | ✅ | 个人微信、微信公众号 | +| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 | +| 飞书 | ✅ | 官方 | +| 钉钉 | ✅ | 官方 | +| Satori | ✅ | | +| Discord | ✅ | 官方 | +| Telegram | ✅ | 官方 | +| Slack | ✅ | 官方 | +| LINE | ✅ | 官方 | +| KOOK | ✅ | 官方 | +| Email | ✅ | 只 Matrix、Satori | +| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 | + +--- + +## 支持的大模型与集成 + +| 提供商 | 类型 | 状态 | +|--------|------|------| +| [OpenAI](https://platform.openai.com/) | LLM | ✅ | +| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | +| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | +| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | +| [xAI](https://x.ai/) | LLM | ✅ | +| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | +| [智谱AI](https://open.bigmodel.cn/) | LLM | ✅ | +| [Ollama](https://ollama.com/) | 本地 LLM | ✅ | +| [LM Studio](https://lmstudio.ai/) | 本地 LLM | ✅ | +| [Dify](https://dify.ai) | LLMOps | ✅ | +| [MCP](https://modelcontextprotocol.io/) | 协议 | ✅ | +| [SiliconFlow](https://siliconflow.cn/) | 聚合平台 | ✅ | +| [阿里云百炼](https://bailian.console.aliyun.com/) | 聚合平台 | ✅ | +| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 聚合平台 | ✅ | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 聚合平台 | ✅ | +| [GiteeAI](https://ai.gitee.com/) | 聚合平台 | ✅ | +| [胜算云](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 平台 | ✅ | +| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ | +| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ | +| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ | +| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ | +| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ | + +[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features) + +### TTS(语音合成) + +| 平台/模型 | 备注 | +|-----------|------| +| [FishAudio](https://fish.audio/zh-CN/discovery/) | [插件](https://github.com/the-lazy-me/NewChatVoice) | +| [海豚 AI](https://www.ttson.cn/?source=thelazy) | [插件](https://github.com/the-lazy-me/NewChatVoice) | +| [AzureTTS](https://portal.azure.com/) | [插件](https://github.com/Ingnaryk/LangBot_AzureTTS) | + +### 文生图 + +| 平台/模型 | 备注 | +|-----------|------| +| 阿里云百炼 | [插件](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) | + +--- + +## 为什么选择 LangBot? + +| 使用场景 | LangBot 如何帮助 | +|----------|------------------| +| **客户服务** | 将 AI Agent 部署到微信/企微/钉钉/飞书,基于知识库自动回答用户问题 | +| **内部工具** | 将 n8n/Dify 工作流接入企微/钉钉,实现业务流程自动化 | +| **社群运营** | 在 QQ/Discord 群中使用 AI 驱动的内容审核与智能互动 | +| **多平台触达** | 一个机器人,覆盖所有平台。通过统一面板集中管理 | + +--- + +## 在线演示 + +**立即体验:** https://demo.langbot.dev/ +- 邮箱:`demo@langbot.app` +- 密码:`langbot123456` + +*注意:公开演示环境,请不要在其中填入任何敏感信息。* + +--- + +## 为 AI Agent 而生 🤖 + +LangBot **从设计上就对 Agent 友好** —— 你的编码 Agent(Claude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、扩展和部署 LangBot: + +- **MCP Server** —— LangBot 内置 [Model Context Protocol](https://modelcontextprotocol.io/) 端点 `/mcp`,与 HTTP API 对齐,Agent 可编程式管理机器人、流水线、插件和模型。使用同一套 API Key 鉴权(可在 `config.yaml` 配置全局 Key,或使用用户 Key),无需登录流程。在 Web 面板的 **API 与 MCP** 标签页中配置。 +- **仓库内 Skills** —— [`skills/`](skills/) 目录是使用 LangBot 的**唯一事实来源**:插件开发、核心开发、端到端测试、部署,以及操作 LangBot / LangBot Space MCP Server。把 Agent 指向这个目录,它就知道如何动手。 +- **AGENTS.md** —— 每个仓库都提供 [`AGENTS.md`](AGENTS.md)(软链到 `CLAUDE.md`),描述架构、规范,以及「API 变更必须同步更新 MCP Server 和 skills」的约定。 +- **`llms.txt`** —— 面向 LLM 的机器可读项目上下文已发布在官网。 + +> **云端 / 市场:** [LangBot Space](https://space.langbot.app) 同样开放 MCP Server,Agent 可搜索和查看插件 / MCP / Skill 市场,使用 Personal Access Token 鉴权。 + +--- + +## 社区 + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) +[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/DxZZcNxM1W) + +- [Discord 社区](https://discord.gg/wdNEHETs87) +- [QQ 社区群](https://qm.qq.com/q/DxZZcNxM1W) + +--- + +## Star 趋势 + +[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) + +--- + +## 贡献者 + +感谢所有[贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)对 LangBot 的帮助: + + + + + + diff --git a/README_ES.md b/README_ES.md new file mode 100644 index 0000000..73061fc --- /dev/null +++ b/README_ES.md @@ -0,0 +1,197 @@ +

+ +LangBot + + +

+ +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt + +

Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.

+

Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.

+ +[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / Español / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) +python +[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) + +Inicio | +Características | +Documentación | +API | +Mercado de Plugins | +Hoja de Ruta + +
+ +

+ +--- + +## ¿Qué es LangBot? + +LangBot es una **plataforma de código abierto y grado de producción** para construir bots de mensajería instantánea impulsados por IA. Conecta modelos de lenguaje de gran escala (LLMs) con cualquier plataforma de chat, permitiéndole crear agentes inteligentes que pueden conversar, ejecutar tareas e integrarse con sus flujos de trabajo existentes. + +

+Panel de gestión web de LangBot — monitoreo en tiempo real de volumen de mensajes, llamadas a modelos, tasa de éxito y sesiones activas +

+ +### Capacidades Clave + +- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com). +- **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. +- **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas. +- **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/). +- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML. +- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones. + +[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features) + +📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). + +--- + +## 😎 Manténgase Actualizado + +Haga clic en los botones Star y Watch en la esquina superior derecha del repositorio para obtener las últimas actualizaciones. + +![star gif](https://langbot.app/star.gif) + +## Inicio Rápido + +### ☁️ LangBot Cloud (Recomendado) + +**[LangBot Cloud](https://space.langbot.app/cloud)** — Sin despliegue, listo para usar. + +### Lanzamiento en una línea + +```bash +uvx langbot +``` + +> Requiere [uv](https://docs.astral.sh/uv/getting-started/installation/). Visite http://localhost:5300 — listo. + +### Docker Compose + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker +docker compose --profile all up -d +``` + +### Despliegue en la Nube con un Clic + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) + +**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) + +--- + +## Plataformas Soportadas + +| Plataforma | Estado | Notas | +|----------|--------|-------| +| Discord | ✅ | Oficial | +| Telegram | ✅ | Oficial | +| Slack | ✅ | Oficial | +| LINE | ✅ | Oficial | +| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) | +| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot | +| WeChat | ✅ | Personal y Cuenta Oficial | +| Lark | ✅ | Oficial | +| DingTalk | ✅ | Oficial | +| KOOK | ✅ | Oficial | +| Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más | + +--- + +## LLMs e Integraciones Soportadas + +| Proveedor | Tipo | Estado | +|----------|------|--------| +| [OpenAI](https://platform.openai.com/) | LLM | ✅ | +| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | +| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | +| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | +| [xAI](https://x.ai/) | LLM | ✅ | +| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | +| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | +| [Ollama](https://ollama.com/) | LLM Local | ✅ | +| [LM Studio](https://lmstudio.ai/) | LLM Local | ✅ | +| [Dify](https://dify.ai) | LLMOps | ✅ | +| [MCP](https://modelcontextprotocol.io/) | Protocolo | ✅ | +| [SiliconFlow](https://siliconflow.cn/) | Pasarela | ✅ | +| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Pasarela | ✅ | +| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Pasarela | ✅ | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Pasarela | ✅ | +| [GiteeAI](https://ai.gitee.com/) | Pasarela | ✅ | +| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plataforma GPU | ✅ | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ | +| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ | +| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ | + +[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features) + +--- + +## ¿Por qué LangBot? + +| Caso de Uso | Cómo Ayuda LangBot | +|----------|-------------------| +| **Atención al cliente** | Despliegue agentes de IA en Slack/Discord/Telegram que respondan preguntas usando su base de conocimientos | +| **Herramientas internas** | Conecte flujos de trabajo de n8n/Dify a WeCom/DingTalk para procesos empresariales automatizados | +| **Gestión de comunidades** | Modere grupos de QQ/Discord con filtrado de contenido e interacción impulsados por IA | +| **Presencia multiplataforma** | Un solo bot, todas las plataformas. Gestione desde un único panel de control | + +--- + +## Demo en Vivo + +**Pruébelo ahora:** https://demo.langbot.dev/ +- Correo electrónico: `demo@langbot.app` +- Contraseña: `langbot123456` + +*Nota: Entorno de demostración público. No ingrese información confidencial.* + +## Diseñado para Agentes de IA 🤖 + +LangBot es **agent-friendly por diseño** —— tus agentes de codificación (Claude Code, Codex, Copilot, Cursor, …) pueden operar, extender y desplegar LangBot con soporte de primera clase: + +- **Servidor MCP** —— LangBot expone un endpoint integrado de [Model Context Protocol](https://modelcontextprotocol.io/) en `/mcp`, replicando la API HTTP para que un agente gestione bots, pipelines, plugins y modelos de forma programática. Autentícate con la misma API key (configura una clave global en `config.yaml` o usa una clave por usuario) —— sin flujo de login. Configúralo en la pestaña **API & MCP** del panel web. +- **Skills en el repositorio** —— El directorio [`skills/`](skills/) es la **única fuente de verdad** para trabajar con LangBot: desarrollo de plugins, desarrollo del core, pruebas end-to-end, despliegue y operación de los servidores MCP de LangBot / LangBot Space. Apunta tu agente a este directorio y sabrá cómo construir. +- **AGENTS.md** —— Cada repo incluye un [`AGENTS.md`](AGENTS.md) (enlazado simbólicamente a `CLAUDE.md`) que describe la arquitectura, las convenciones y la regla de que los cambios en la API deben mantener sincronizados el servidor MCP y los skills. +- **`llms.txt`** —— El contexto del proyecto legible por máquina para LLMs está publicado en el sitio web. + +> **Nube / Marketplace:** [LangBot Space](https://space.langbot.app) también expone un servidor MCP para que los agentes busquen e inspeccionen el marketplace de plugins / MCP / skills, autenticados con un Personal Access Token. + +--- + +## Comunidad + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) + +- [Comunidad de Discord](https://discord.gg/wdNEHETs87) + +--- + +## Historial de Stars + +[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) + +--- + +## Colaboradores + +Gracias a todos los [colaboradores](https://github.com/langbot-app/LangBot/graphs/contributors) que han ayudado a mejorar LangBot: + + + + diff --git a/README_FR.md b/README_FR.md new file mode 100644 index 0000000..8c0b032 --- /dev/null +++ b/README_FR.md @@ -0,0 +1,197 @@ +

+ +LangBot + + +

+ +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt + +

Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.

+

Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.

+ +[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / Français / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) +python +[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) + +Accueil | +Fonctionnalités | +Documentation | +API | +Marché des Plugins | +Feuille de Route + +
+ +

+ +--- + +## Qu'est-ce que LangBot ? + +LangBot est une **plateforme open-source de niveau production** pour créer des bots de messagerie instantanée alimentés par l'IA. Elle connecte les grands modèles de langage (LLMs) à n'importe quelle plateforme de chat, vous permettant de créer des agents intelligents capables de converser, d'exécuter des tâches et de s'intégrer à vos workflows existants. + +

+Tableau de bord de gestion web LangBot — surveillance en temps réel du volume de messages, des appels de modèles, du taux de réussite et des sessions actives +

+ +### Capacités Clés + +- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com). +- **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. +- **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises. +- **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/). +- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise. +- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions. + +[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features) + +📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). + +--- + +## 😎 Restez à Jour + +Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt pour obtenir les dernières mises à jour. + +![star gif](https://langbot.app/star.gif) + +## Démarrage Rapide + +### ☁️ LangBot Cloud (Recommandé) + +**[LangBot Cloud](https://space.langbot.app/cloud)** — Sans déploiement, prêt à utiliser. + +### Lancement en une ligne + +```bash +uvx langbot +``` + +> Nécessite [uv](https://docs.astral.sh/uv/getting-started/installation/). Visitez http://localhost:5300 — c'est prêt. + +### Docker Compose + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker +docker compose --profile all up -d +``` + +### Déploiement Cloud en un Clic + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) + +**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) + +--- + +## Plateformes Supportées + +| Plateforme | Statut | Notes | +|----------|--------|-------| +| Discord | ✅ | Officiel | +| Telegram | ✅ | Officiel | +| Slack | ✅ | Officiel | +| LINE | ✅ | Officiel | +| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) | +| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot | +| WeChat | ✅ | Personnel & Compte Officiel | +| Lark | ✅ | Officiel | +| DingTalk | ✅ | Officiel | +| KOOK | ✅ | Officiel | +| Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. | + +--- + +## LLMs et Intégrations Supportés + +| Fournisseur | Type | Statut | +|----------|------|--------| +| [OpenAI](https://platform.openai.com/) | LLM | ✅ | +| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | +| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | +| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | +| [xAI](https://x.ai/) | LLM | ✅ | +| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | +| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | +| [Ollama](https://ollama.com/) | LLM Local | ✅ | +| [LM Studio](https://lmstudio.ai/) | LLM Local | ✅ | +| [Dify](https://dify.ai) | LLMOps | ✅ | +| [MCP](https://modelcontextprotocol.io/) | Protocole | ✅ | +| [SiliconFlow](https://siliconflow.cn/) | Passerelle | ✅ | +| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Passerelle | ✅ | +| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Passerelle | ✅ | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ | +| [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ | +| [接口 AI](https://jiekou.ai/) | Passerelle | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | Passerelle | ✅ | +| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ | +| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ | + +[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features) + +--- + +## Pourquoi LangBot ? + +| Cas d'Usage | Comment LangBot Aide | +|----------|-------------------| +| **Support Client** | Déployez des agents IA sur Slack/Discord/Telegram qui répondent aux questions en utilisant votre base de connaissances | +| **Outils Internes** | Connectez les workflows n8n/Dify à WeCom/DingTalk pour automatiser vos processus métier | +| **Gestion de Communauté** | Modérez les groupes QQ/Discord avec un filtrage de contenu et des interactions alimentés par l'IA | +| **Présence Multi-plateforme** | Un seul bot, toutes les plateformes. Gérez tout depuis un tableau de bord unique | + +--- + +## Démo en Ligne + +**Essayez maintenant :** https://demo.langbot.dev/ +- Email : `demo@langbot.app` +- Mot de passe : `langbot123456` + +*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.* + +## Conçu pour les agents IA 🤖 + +LangBot est **agent-friendly par conception** —— vos agents de codage (Claude Code, Codex, Copilot, Cursor, …) peuvent exploiter, étendre et déployer LangBot avec un support de premier ordre : + +- **Serveur MCP** —— LangBot expose un endpoint [Model Context Protocol](https://modelcontextprotocol.io/) intégré sur `/mcp`, reflétant l'API HTTP pour qu'un agent gère bots, pipelines, plugins et modèles de façon programmatique. Authentifiez-vous avec la même clé API (définissez une clé globale dans `config.yaml` ou utilisez une clé par utilisateur) —— sans flux de connexion. Configurez-le dans l'onglet **API & MCP** du panneau web. +- **Skills dans le dépôt** —— Le répertoire [`skills/`](skills/) est la **source unique de vérité** pour travailler avec LangBot : développement de plugins, développement du cœur, tests de bout en bout, déploiement et exploitation des serveurs MCP de LangBot / LangBot Space. Pointez votre agent vers ce répertoire et il saura construire. +- **AGENTS.md** —— Chaque dépôt fournit un [`AGENTS.md`](AGENTS.md) (lien symbolique vers `CLAUDE.md`) décrivant l'architecture, les conventions et la règle selon laquelle les changements d'API doivent garder le serveur MCP et les skills synchronisés. +- **`llms.txt`** —— Le contexte projet lisible par machine pour les LLM est publié sur le site web. + +> **Cloud / Marketplace :** [LangBot Space](https://space.langbot.app) expose également un serveur MCP pour que les agents recherchent et inspectent le marketplace de plugins / MCP / skills, authentifiés avec un Personal Access Token. + +--- + +## Communauté + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) + +- [Communauté Discord](https://discord.gg/wdNEHETs87) + +--- + +## Historique des Stars + +[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) + +--- + +## Contributeurs + +Merci à tous les [contributeurs](https://github.com/langbot-app/LangBot/graphs/contributors) qui ont aidé à améliorer LangBot : + + + + diff --git a/README_JP.md b/README_JP.md new file mode 100644 index 0000000..10d765f --- /dev/null +++ b/README_JP.md @@ -0,0 +1,197 @@ +

+ +LangBot + + +

+ +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt + +

AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。

+

Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。

+ +[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / 日本語 / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) +python +[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) + +ホーム | +機能 | +ドキュメント | +API | +プラグインマーケット | +ロードマップ + +
+ +

+ +--- + +## LangBot とは? + +LangBot は、AI搭載のインスタントメッセージングボットを構築するための**オープンソースの本番グレードプラットフォーム**です。大規模言語モデル(LLM)をあらゆるチャットプラットフォームに接続し、会話、タスク実行、既存のワークフローとの統合が可能なインテリジェントエージェントを作成できます。 + +

+LangBot Web 管理パネルのダッシュボード — メッセージ量、モデル呼び出し、成功率、アクティブセッションをリアルタイム監視 +

+ +### 主な機能 + +- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com) と深く統合。 +- **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。 +- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。 +- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。 +- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。 +- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。 + +[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features) + +📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。 + +--- + +## 😎 最新情報を入手 + +リポジトリの右上にある Star と Watch ボタンをクリックして、最新の更新を取得してください。 + +![star gif](https://langbot.app/star.gif) + +## クイックスタート + +### ☁️ LangBot Cloud(推奨) + +**[LangBot Cloud](https://space.langbot.app/cloud)** — デプロイ不要、すぐに使えます。 + +### ワンライン起動 + +```bash +uvx langbot +``` + +> [uv](https://docs.astral.sh/uv/getting-started/installation/) が必要です。http://localhost:5300 にアクセスして完了。 + +### Docker Compose + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker +docker compose --profile all up -d +``` + +### ワンクリッククラウドデプロイ + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) + +**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) + +--- + +## 対応プラットフォーム + +| プラットフォーム | ステータス | 備考 | +|----------|--------|-------| +| Discord | ✅ | 公式 | +| Telegram | ✅ | 公式 | +| Slack | ✅ | 公式 | +| LINE | ✅ | 公式 | +| QQ | ✅ | 個人・公式API(チャンネル・DM・グループ) | +| WeCom | ✅ | 企業WeChat、外部CS、AIボット | +| WeChat | ✅ | 個人・公式アカウント | +| Lark | ✅ | 公式 | +| DingTalk | ✅ | 公式 | +| KOOK | ✅ | 公式 | +| Satori | ✅ | | +| Email | ✅ | Matrix、Satori | +| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 | + +--- + +## 対応LLMと統合 + +| プロバイダー | タイプ | ステータス | +|----------|------|--------| +| [OpenAI](https://platform.openai.com/) | LLM | ✅ | +| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | +| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | +| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | +| [xAI](https://x.ai/) | LLM | ✅ | +| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | +| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | +| [Ollama](https://ollama.com/) | ローカルLLM | ✅ | +| [LM Studio](https://lmstudio.ai/) | ローカルLLM | ✅ | +| [Dify](https://dify.ai) | LLMOps | ✅ | +| [MCP](https://modelcontextprotocol.io/) | プロトコル | ✅ | +| [SiliconFlow](https://siliconflow.cn/) | ゲートウェイ | ✅ | +| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ゲートウェイ | ✅ | +| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ゲートウェイ | ✅ | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ゲートウェイ | ✅ | +| [GiteeAI](https://ai.gitee.com/) | ゲートウェイ | ✅ | +| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPUプラットフォーム | ✅ | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ | +| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ | +| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ | + +[→ すべての統合を表示](https://link.langbot.app/en/docs/features) + +--- + +## なぜ LangBot? + +| ユースケース | LangBot の活用方法 | +|----------|-------------------| +| **カスタマーサポート** | ナレッジベースを活用して質問に回答するAIエージェントをSlack/Discord/Telegramにデプロイ | +| **社内ツール** | n8n/Difyのワークフローを WeCom/DingTalk に接続し、業務プロセスを自動化 | +| **コミュニティ管理** | AI搭載のコンテンツフィルタリングとインタラクションでQQ/Discordグループをモデレーション | +| **マルチプラットフォーム展開** | 1つのボットで全プラットフォームに対応。単一のダッシュボードから管理 | + +--- + +## ライブデモ + +**今すぐ試す:** https://demo.langbot.dev/ +- メール: `demo@langbot.app` +- パスワード: `langbot123456` + +*注意: 公開デモ環境です。機密情報を入力しないでください。* + +## AI エージェントのために 🤖 + +LangBot は **設計段階からエージェントフレンドリー** です。お使いのコーディングエージェント(Claude Code、Codex、Copilot、Cursor など)が、ファーストクラスのサポートで LangBot を操作・拡張・デプロイできます: + +- **MCP サーバー** —— LangBot は組み込みの [Model Context Protocol](https://modelcontextprotocol.io/) エンドポイント `/mcp` を公開し、HTTP API とミラーリングされているため、エージェントがボット・パイプライン・プラグイン・モデルをプログラム的に管理できます。同じ API キーで認証(`config.yaml` でグローバルキーを設定、またはユーザーキーを使用)—— ログインフロー不要。Web パネルの **API & MCP** タブで設定します。 +- **リポジトリ内 Skills** —— [`skills/`](skills/) ディレクトリは LangBot を扱うための**唯一の信頼できる情報源**です:プラグイン開発、コア開発、E2E テスト、デプロイ、LangBot / LangBot Space MCP サーバーの操作。エージェントをこのディレクトリに向ければ、構築方法を理解します。 +- **AGENTS.md** —— すべてのリポジトリに [`AGENTS.md`](AGENTS.md)(`CLAUDE.md` へのシンボリックリンク)があり、アーキテクチャ・規約、そして「API 変更時は MCP サーバーと skills を同期する」というルールを記述しています。 +- **`llms.txt`** —— LLM 向けの機械可読なプロジェクトコンテキストを公式サイトで公開しています。 + +> **クラウド / マーケット:** [LangBot Space](https://space.langbot.app) も MCP サーバーを公開しており、エージェントが Personal Access Token で認証してプラグイン / MCP / Skill マーケットを検索・確認できます。 + +--- + +## コミュニティ + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) + +- [Discord コミュニティ](https://discord.gg/wdNEHETs87) + +--- + +## Star 推移 + +[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) + +--- + +## コントリビューター + +LangBot をより良くするために貢献してくださったすべての[コントリビューター](https://github.com/langbot-app/LangBot/graphs/contributors)に感謝します: + + + + diff --git a/README_KO.md b/README_KO.md new file mode 100644 index 0000000..2a4b19e --- /dev/null +++ b/README_KO.md @@ -0,0 +1,197 @@ +

+ +LangBot + + +

+ +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt + +

AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.

+

Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.

+ +[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / 한국어 / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) +python +[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) + + | +기능 | +문서 | +API | +플러그인 마켓 | +로드맵 + +
+ +

+ +--- + +## LangBot이란? + +LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈소스 프로덕션 등급 플랫폼**입니다. 대규모 언어 모델(LLM)을 모든 채팅 플랫폼에 연결하여 대화, 작업 실행, 기존 워크플로우와의 통합이 가능한 지능형 에이전트를 만들 수 있습니다. + +

+LangBot 웹 관리 패널 대시보드 — 메시지 양, 모델 호출, 성공률, 활성 세션 실시간 모니터링 +

+ +### 핵심 기능 + +- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com) 심층 통합. +- **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원. +- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨. +- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원. +- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요. +- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리. + +[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features) + +📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). + +--- + +## 😎 최신 정보 받기 + +리포지토리 오른쪽 상단의 Star 및 Watch 버튼을 클릭하여 최신 업데이트를 받으세요. + +![star gif](https://langbot.app/star.gif) + +## 빠른 시작 + +### ☁️ LangBot Cloud (추천) + +**[LangBot Cloud](https://space.langbot.app/cloud)** — 배포 없이 바로 사용. + +### 원라인 실행 + +```bash +uvx langbot +``` + +> [uv](https://docs.astral.sh/uv/getting-started/installation/) 설치 필요. http://localhost:5300 방문 — 완료. + +### Docker Compose + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker +docker compose --profile all up -d +``` + +### 원클릭 클라우드 배포 + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) + +**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) + +--- + +## 지원 플랫폼 + +| 플랫폼 | 상태 | 비고 | +|--------|------|------| +| Discord | ✅ | 공식 | +| Telegram | ✅ | 공식 | +| Slack | ✅ | 공식 | +| LINE | ✅ | 공식 | +| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) | +| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot | +| WeChat | ✅ | 개인 및 공식 계정 | +| Lark | ✅ | 공식 | +| DingTalk | ✅ | 공식 | +| KOOK | ✅ | 공식 | +| Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 | + +--- + +## 지원 LLM 및 통합 + +| 제공자 | 유형 | 상태 | +|--------|------|------| +| [OpenAI](https://platform.openai.com/) | LLM | ✅ | +| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | +| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | +| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | +| [xAI](https://x.ai/) | LLM | ✅ | +| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | +| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | +| [Ollama](https://ollama.com/) | 로컬 LLM | ✅ | +| [LM Studio](https://lmstudio.ai/) | 로컬 LLM | ✅ | +| [Dify](https://dify.ai) | LLMOps | ✅ | +| [MCP](https://modelcontextprotocol.io/) | 프로토콜 | ✅ | +| [SiliconFlow](https://siliconflow.cn/) | 게이트웨이 | ✅ | +| [Aliyun Bailian](https://bailian.console.aliyun.com/) | 게이트웨이 | ✅ | +| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 게이트웨이 | ✅ | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 게이트웨이 | ✅ | +| [GiteeAI](https://ai.gitee.com/) | 게이트웨이 | ✅ | +| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 플랫폼 | ✅ | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ | +| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ | +| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ | + +[→ 모든 통합 보기](https://link.langbot.app/en/docs/features) + +--- + +## 왜 LangBot인가? + +| 사용 사례 | LangBot 활용 방법 | +|-----------|-------------------| +| **고객 지원** | 지식 베이스를 활용하여 질문에 답변하는 AI 에이전트를 Slack/Discord/Telegram에 배포 | +| **내부 도구** | n8n/Dify 워크플로우를 WeCom/DingTalk에 연결하여 비즈니스 프로세스 자동화 | +| **커뮤니티 관리** | AI 기반 콘텐츠 필터링 및 상호작용으로 QQ/Discord 그룹 관리 | +| **멀티 플랫폼** | 하나의 봇으로 모든 플랫폼 지원. 단일 대시보드에서 관리 | + +--- + +## 라이브 데모 + +**지금 체험:** https://demo.langbot.dev/ +- 이메일: `demo@langbot.app` +- 비밀번호: `langbot123456` + +*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.* + +## AI 에이전트를 위한 설계 🤖 + +LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩 에이전트(Claude Code, Codex, Copilot, Cursor 등)가 일급 지원으로 LangBot을 운영·확장·배포할 수 있습니다: + +- **MCP 서버** —— LangBot은 내장 [Model Context Protocol](https://modelcontextprotocol.io/) 엔드포인트 `/mcp`를 제공하며, HTTP API와 동일하게 미러링되어 에이전트가 봇·파이프라인·플러그인·모델을 프로그래밍 방식으로 관리할 수 있습니다. 동일한 API 키로 인증하며(`config.yaml`에 전역 키 설정 또는 사용자 키 사용) 로그인 절차가 필요 없습니다. 웹 패널의 **API & MCP** 탭에서 설정합니다. +- **저장소 내 Skills** —— [`skills/`](skills/) 디렉터리는 LangBot 작업의 **단일 진실 공급원**입니다: 플러그인 개발, 코어 개발, E2E 테스트, 배포, LangBot / LangBot Space MCP 서버 운영. 에이전트를 이 디렉터리로 안내하면 빌드 방법을 알게 됩니다. +- **AGENTS.md** —— 모든 저장소에는 [`AGENTS.md`](AGENTS.md)(`CLAUDE.md`로 심볼릭 링크)가 있으며 아키텍처, 규약, 그리고 API 변경 시 MCP 서버와 skills를 동기화해야 한다는 규칙을 설명합니다. +- **`llms.txt`** —— LLM을 위한 기계 판독 가능한 프로젝트 컨텍스트가 웹사이트에 게시되어 있습니다. + +> **클라우드 / 마켓플레이스:** [LangBot Space](https://space.langbot.app)도 MCP 서버를 제공하여 에이전트가 Personal Access Token으로 인증해 플러그인 / MCP / Skill 마켓플레이스를 검색하고 조회할 수 있습니다. + +--- + +## 커뮤니티 + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) + +- [Discord 커뮤니티](https://discord.gg/wdNEHETs87) + +--- + +## Star 추이 + +[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) + +--- + +## 기여자 + +LangBot을 더 나은 프로젝트로 만들어 주신 모든 [기여자](https://github.com/langbot-app/LangBot/graphs/contributors)분들께 감사드립니다: + + + + diff --git a/README_RU.md b/README_RU.md new file mode 100644 index 0000000..ef2fed6 --- /dev/null +++ b/README_RU.md @@ -0,0 +1,197 @@ +

+ +LangBot + + +

+ +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt + +

Платформа производственного уровня для создания агентных IM-ботов.

+

Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.

+ +[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / Русский / [Tiếng Việt](README_VI.md) + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) +python +[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) + +Главная | +Возможности | +Документация | +API | +Магазин плагинов | +Дорожная карта + +
+ +

+ +--- + +## Что такое LangBot? + +LangBot — это **платформа с открытым исходным кодом производственного уровня** для создания ИИ-ботов в мессенджерах. Она связывает большие языковые модели (LLM) с любой чат-платформой, позволяя создавать интеллектуальных агентов, которые могут вести диалоги, выполнять задачи и интегрироваться с вашими существующими рабочими процессами. + +

+Панель веб-управления LangBot — мониторинг объёма сообщений, вызовов моделей, успешности и активных сессий в реальном времени +

+ +### Ключевые возможности + +- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com). +- **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. +- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде. +- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/). +- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется. +- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений. + +[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features) + +📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). + +--- + +## 😎 Оставайтесь в курсе + +Нажмите кнопки Star и Watch в правом верхнем углу репозитория, чтобы получать последние обновления. + +![star gif](https://langbot.app/star.gif) + +## Быстрый старт + +### ☁️ LangBot Cloud (Рекомендуется) + +**[LangBot Cloud](https://space.langbot.app/cloud)** — Без развёртывания, готово к использованию. + +### Запуск одной командой + +```bash +uvx langbot +``` + +> Требуется [uv](https://docs.astral.sh/uv/getting-started/installation/). Откройте http://localhost:5300 — готово. + +### Docker Compose + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker +docker compose --profile all up -d +``` + +### Облачное развертывание одним кликом + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) + +**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) + +--- + +## Поддерживаемые платформы + +| Платформа | Статус | Примечания | +|-----------|--------|------------| +| Discord | ✅ | Официальный | +| Telegram | ✅ | Официальный | +| Slack | ✅ | Официальный | +| LINE | ✅ | Официальный | +| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) | +| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот | +| WeChat | ✅ | Личный и официальный аккаунт | +| Lark | ✅ | Официальный | +| DingTalk | ✅ | Официальный | +| KOOK | ✅ | Официальный | +| Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Поддерживает несколько платформ через мосты, включая Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip и другие | + +--- + +## Поддерживаемые LLM и интеграции + +| Провайдер | Тип | Статус | +|-----------|-----|--------| +| [OpenAI](https://platform.openai.com/) | LLM | ✅ | +| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | +| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | +| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | +| [xAI](https://x.ai/) | LLM | ✅ | +| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | +| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | +| [Ollama](https://ollama.com/) | Локальный LLM | ✅ | +| [LM Studio](https://lmstudio.ai/) | Локальный LLM | ✅ | +| [Dify](https://dify.ai) | LLMOps | ✅ | +| [MCP](https://modelcontextprotocol.io/) | Протокол | ✅ | +| [SiliconFlow](https://siliconflow.cn/) | Шлюз | ✅ | +| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Шлюз | ✅ | +| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ | +| [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | Шлюз | ✅ | +| [接口 AI](https://jiekou.ai/) | Шлюз | ✅ | +| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ | +| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ | + +[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features) + +--- + +## Почему LangBot? + +| Сценарий использования | Как помогает LangBot | +|------------------------|----------------------| +| **Поддержка клиентов** | Разверните ИИ-агентов в Slack/Discord/Telegram, которые отвечают на вопросы, используя вашу базу знаний | +| **Внутренние инструменты** | Подключите рабочие процессы n8n/Dify к WeCom/DingTalk для автоматизации бизнес-процессов | +| **Управление сообществом** | Модерируйте группы QQ/Discord с помощью ИИ-фильтрации контента и взаимодействия | +| **Мультиплатформенное присутствие** | Один бот — все платформы. Управляйте из единой панели | + +--- + +## Демо + +**Попробуйте прямо сейчас:** https://demo.langbot.dev/ +- Email: `demo@langbot.app` +- Пароль: `langbot123456` + +*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.* + +## Создано для ИИ-агентов 🤖 + +LangBot **дружелюбен к агентам по своей архитектуре** —— ваши кодинг-агенты (Claude Code, Codex, Copilot, Cursor и др.) могут управлять, расширять и развёртывать LangBot с первоклассной поддержкой: + +- **MCP-сервер** —— LangBot предоставляет встроенную конечную точку [Model Context Protocol](https://modelcontextprotocol.io/) по адресу `/mcp`, зеркалирующую HTTP API, чтобы агент мог программно управлять ботами, пайплайнами, плагинами и моделями. Аутентификация той же API-ключом (задайте глобальный ключ в `config.yaml` или используйте пользовательский ключ) —— без процедуры входа. Настраивается на вкладке **API & MCP** веб-панели. +- **Skills в репозитории** —— Каталог [`skills/`](skills/) является **единственным источником истины** для работы с LangBot: разработка плагинов, разработка ядра, сквозное тестирование, развёртывание и работа с MCP-серверами LangBot / LangBot Space. Направьте агента в этот каталог, и он будет знать, как собирать. +- **AGENTS.md** —— Каждый репозиторий содержит [`AGENTS.md`](AGENTS.md) (символическая ссылка на `CLAUDE.md`), описывающий архитектуру, соглашения и правило: изменения API должны синхронизировать MCP-сервер и skills. +- **`llms.txt`** —— Машиночитаемый контекст проекта для LLM опубликован на сайте. + +> **Облако / Маркетплейс:** [LangBot Space](https://space.langbot.app) также предоставляет MCP-сервер, чтобы агенты могли искать и просматривать маркетплейс плагинов / MCP / skills, аутентифицируясь с помощью Personal Access Token. + +--- + +## Сообщество + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) + +- [Сообщество Discord](https://discord.gg/wdNEHETs87) + +--- + +## История Stars + +[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) + +--- + +## Участники + +Спасибо всем [участникам](https://github.com/langbot-app/LangBot/graphs/contributors), которые помогли сделать LangBot лучше: + + + + diff --git a/README_TW.md b/README_TW.md new file mode 100644 index 0000000..9d741e9 --- /dev/null +++ b/README_TW.md @@ -0,0 +1,215 @@ +

+ +LangBot + + +

+ +Featured|HelloGitHub + +

生產級 AI 即時通訊機器人開發平台。

+

快速建構、除錯和部署 AI 機器人到微信、QQ、飛書、Slack、Discord、Telegram 等平台。

+ +[English](README.md) / [简体中文](README_CN.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) +[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) +python +[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) +[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) + +官網 | +特性 | +文件 | +API | +外掛市場 | +路線圖 + +
+ +

+ +--- + +## 什麼是 LangBot? + +LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時通訊機器人。它將大語言模型(LLM)連接到各種聊天平台,幫助你創建能夠對話、執行任務、並整合到現有工作流程中的智能 Agent。 + +

+LangBot Web 管理面板儀表板 — 即時監控訊息量、模型調用、成功率與活躍工作階段 +

+ +### 核心能力 + +- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、 [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。 +- **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。 +- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。 +- **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。 +- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。 +- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。 + +[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features) + +📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 + +--- + +## 😎 保持更新 + +點擊倉庫右上角 Star 和 Watch 按鈕,獲取最新動態。 + +![star gif](https://langbot.app/star.gif) + +## 快速開始 + +### ☁️ LangBot Cloud(推薦) + +**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,開箱即用。 + +### 一鍵啟動 + +```bash +uvx langbot +``` + +> 需要安裝 [uv](https://docs.astral.sh/uv/getting-started/installation/)。訪問 http://localhost:5300 即可使用。 + +### Docker Compose + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker +docker compose --profile all up -d +``` + +### 一鍵雲端部署 + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) + +**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) + +--- + +## 支援的平台 + +| 平台 | 狀態 | 備註 | +|------|------|------| +| Discord | ✅ | 官方 | +| Telegram | ✅ | 官方 | +| Slack | ✅ | 官方 | +| LINE | ✅ | 官方 | +| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) | +| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 | +| 微信 | ✅ | 個人微信、微信公眾號 | +| 飛書 | ✅ | 官方 | +| 釘釘 | ✅ | 官方 | +| KOOK | ✅ | 官方 | +| Satori | ✅ | | +| Email | ✅ | 只 Matrix、Satori | +| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 | + +--- + +## 支援的大模型與整合 + +| 提供商 | 類型 | 狀態 | +|--------|------|------| +| [OpenAI](https://platform.openai.com/) | LLM | ✅ | +| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | +| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | +| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | +| [xAI](https://x.ai/) | LLM | ✅ | +| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | +| [智譜AI](https://open.bigmodel.cn/) | LLM | ✅ | +| [Ollama](https://ollama.com/) | 本地 LLM | ✅ | +| [LM Studio](https://lmstudio.ai/) | 本地 LLM | ✅ | +| [Dify](https://dify.ai) | LLMOps | ✅ | +| [MCP](https://modelcontextprotocol.io/) | 協議 | ✅ | +| [SiliconFlow](https://siliconflow.cn/) | 聚合平台 | ✅ | +| [阿里雲百煉](https://bailian.console.aliyun.com/) | 聚合平台 | ✅ | +| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | 聚合平台 | ✅ | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | 聚合平台 | ✅ | +| [GiteeAI](https://ai.gitee.com/) | 聚合平台 | ✅ | +| [勝算雲](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 平台 | ✅ | +| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ | +| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ | + +### TTS(語音合成) + +| 平台/模型 | 備註 | +|-----------|------| +| [FishAudio](https://fish.audio/zh-CN/discovery/) | [外掛](https://github.com/the-lazy-me/NewChatVoice) | +| [海豚 AI](https://www.ttson.cn/?source=thelazy) | [外掛](https://github.com/the-lazy-me/NewChatVoice) | +| [AzureTTS](https://portal.azure.com/) | [外掛](https://github.com/Ingnaryk/LangBot_AzureTTS) | + +### 文生圖 + +| 平台/模型 | 備註 | +|-----------|------| +| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) | + +[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features) + +--- + +## 為什麼選擇 LangBot? + +| 使用場景 | LangBot 如何幫助 | +|----------|------------------| +| **客戶服務** | 將 AI Agent 部署到微信/企微/釘釘/飛書,基於知識庫自動回答使用者問題 | +| **內部工具** | 將 n8n/Dify 工作流接入企微/釘釘,實現業務流程自動化 | +| **社群運營** | 在 QQ/Discord 群中使用 AI 驅動的內容審核與智能互動 | +| **多平台觸達** | 一個機器人,覆蓋所有平台。透過統一面板集中管理 | + +--- + +## 線上演示 + +**立即體驗:** https://demo.langbot.dev/ +- 信箱:`demo@langbot.app` +- 密碼:`langbot123456` + +*注意:公開演示環境,請不要在其中填入任何敏感資訊。* + +## 為 AI Agent 而生 🤖 + +LangBot **從設計上就對 Agent 友善** —— 你的編碼 Agent(Claude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、擴充和部署 LangBot: + +- **MCP Server** —— LangBot 內建 [Model Context Protocol](https://modelcontextprotocol.io/) 端點 `/mcp`,與 HTTP API 對齊,Agent 可程式化管理機器人、流水線、外掛和模型。使用同一套 API Key 鑑權(可在 `config.yaml` 設定全域 Key,或使用使用者 Key),無需登入流程。在 Web 面板的 **API 與 MCP** 分頁中設定。 +- **倉庫內 Skills** —— [`skills/`](skills/) 目錄是使用 LangBot 的**唯一事實來源**:外掛開發、核心開發、端到端測試、部署,以及操作 LangBot / LangBot Space MCP Server。把 Agent 指向這個目錄,它就知道如何動手。 +- **AGENTS.md** —— 每個倉庫都提供 [`AGENTS.md`](AGENTS.md)(軟連結到 `CLAUDE.md`),描述架構、規範,以及「API 變更必須同步更新 MCP Server 和 skills」的約定。 +- **`llms.txt`** —— 面向 LLM 的機器可讀專案上下文已發布在官網。 + +> **雲端 / 市集:** [LangBot Space](https://space.langbot.app) 同樣開放 MCP Server,Agent 可搜尋和檢視外掛 / MCP / Skill 市集,使用 Personal Access Token 鑑權。 + +--- + +## 社群 + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) +[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum) + +- [Discord 社群](https://discord.gg/wdNEHETs87) +- [QQ 社群群](https://qm.qq.com/q/JLi38whHum) + +--- + +## Star 趨勢 + +[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) + +--- + +## 貢獻者 + +感謝所有[貢獻者](https://github.com/langbot-app/LangBot/graphs/contributors)對 LangBot 的幫助: + + + + diff --git a/README_VI.md b/README_VI.md new file mode 100644 index 0000000..d1cdbd9 --- /dev/null +++ b/README_VI.md @@ -0,0 +1,197 @@ +

+ +LangBot + + +

+ +LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt + +

Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.

+

Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.

+ +[English](README.md) / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / Tiếng Việt + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest) +python +[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) + +Trang chủ | +Tính năng | +Tài liệu | +API | +Chợ Plugin | +Lộ trình + +
+ +

+ +--- + +## LangBot là gì? + +LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để xây dựng bot nhắn tin tức thời được hỗ trợ bởi AI. Nó kết nối các Mô hình Ngôn ngữ Lớn (LLM) với bất kỳ nền tảng chat nào, cho phép bạn tạo các agent thông minh có thể trò chuyện, thực hiện tác vụ và tích hợp với quy trình làm việc hiện có của bạn. + +

+Bảng điều khiển quản lý web LangBot — giám sát thời gian thực khối lượng tin nhắn, lệnh gọi mô hình, tỷ lệ thành công và phiên hoạt động +

+ +### Khả năng chính + +- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com). +- **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK. +- **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng. +- **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/). +- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML. +- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ. + +[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features) + +📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). + +--- + +## 😎 Cập nhật Mới nhất + +Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu trữ để nhận các bản cập nhật mới nhất. + +![star gif](https://langbot.app/star.gif) + +## Bắt đầu nhanh + +### ☁️ LangBot Cloud (Khuyên dùng) + +**[LangBot Cloud](https://space.langbot.app/cloud)** — Không cần triển khai, sẵn sàng sử dụng. + +### Khởi chạy một dòng + +```bash +uvx langbot +``` + +> Yêu cầu [uv](https://docs.astral.sh/uv/getting-started/installation/). Truy cập http://localhost:5300 — xong. + +### Docker Compose + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker +docker compose --profile all up -d +``` + +### Triển khai đám mây một cú nhấp + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) + +**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) + +--- + +## Nền tảng được hỗ trợ + +| Nền tảng | Trạng thái | Ghi chú | +|----------|--------|-------| +| Discord | ✅ | Chính thức | +| Telegram | ✅ | Chính thức | +| Slack | ✅ | Chính thức | +| LINE | ✅ | Chính thức | +| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) | +| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot | +| WeChat | ✅ | Cá nhân & Tài khoản công khai | +| Lark | ✅ | Chính thức | +| DingTalk | ✅ | Chính thức | +| KOOK | ✅ | Chính thức | +| Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Hỗ trợ nhiều nền tảng qua bridge như Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip và hơn thế nữa | + +--- + +## LLM và tích hợp được hỗ trợ + +| Nhà cung cấp | Loại | Trạng thái | +|----------|------|--------| +| [OpenAI](https://platform.openai.com/) | LLM | ✅ | +| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | +| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | +| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | +| [xAI](https://x.ai/) | LLM | ✅ | +| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | +| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | +| [Ollama](https://ollama.com/) | LLM cục bộ | ✅ | +| [LM Studio](https://lmstudio.ai/) | LLM cục bộ | ✅ | +| [Dify](https://dify.ai) | LLMOps | ✅ | +| [MCP](https://modelcontextprotocol.io/) | Giao thức | ✅ | +| [SiliconFlow](https://siliconflow.cn/) | Cổng | ✅ | +| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Cổng | ✅ | +| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Cổng | ✅ | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Cổng | ✅ | +| [GiteeAI](https://ai.gitee.com/) | Cổng | ✅ | +| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Nền tảng GPU | ✅ | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ | +| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ | +| [接口 AI](https://jiekou.ai/) | Cổng | ✅ | +| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ | + +[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features) + +--- + +## Tại sao chọn LangBot? + +| Trường hợp sử dụng | LangBot giúp như thế nào | +|----------|-------------------| +| **Hỗ trợ khách hàng** | Triển khai agent AI trên Slack/Discord/Telegram để trả lời câu hỏi bằng cơ sở kiến thức của bạn | +| **Công cụ nội bộ** | Kết nối quy trình n8n/Dify với WeCom/DingTalk để tự động hóa quy trình kinh doanh | +| **Quản lý cộng đồng** | Quản lý nhóm QQ/Discord với tính năng lọc nội dung và tương tác được hỗ trợ bởi AI | +| **Đa nền tảng** | Một bot, tất cả nền tảng. Quản lý từ một bảng điều khiển duy nhất | + +--- + +## Demo trực tuyến + +**Thử ngay:** https://demo.langbot.dev/ +- Email: `demo@langbot.app` +- Mật khẩu: `langbot123456` + +*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.* + +## Được xây dựng cho AI Agent 🤖 + +LangBot **thân thiện với agent ngay từ thiết kế** —— các coding agent của bạn (Claude Code, Codex, Copilot, Cursor, …) có thể vận hành, mở rộng và triển khai LangBot với sự hỗ trợ hạng nhất: + +- **MCP Server** —— LangBot cung cấp endpoint [Model Context Protocol](https://modelcontextprotocol.io/) tích hợp tại `/mcp`, phản chiếu HTTP API để agent quản lý bot, pipeline, plugin và model theo cách lập trình. Xác thực bằng cùng một API key (đặt key toàn cục trong `config.yaml` hoặc dùng key theo người dùng) —— không cần luồng đăng nhập. Cấu hình tại tab **API & MCP** trong bảng điều khiển Web. +- **Skills trong repo** —— Thư mục [`skills/`](skills/) là **nguồn sự thật duy nhất** để làm việc với LangBot: phát triển plugin, phát triển core, kiểm thử end-to-end, triển khai và vận hành MCP Server của LangBot / LangBot Space. Trỏ agent của bạn vào thư mục này và nó sẽ biết cách xây dựng. +- **AGENTS.md** —— Mỗi repo đều có [`AGENTS.md`](AGENTS.md) (liên kết tượng trưng tới `CLAUDE.md`) mô tả kiến trúc, quy ước và quy tắc rằng thay đổi API phải giữ MCP Server và skills đồng bộ. +- **`llms.txt`** —— Ngữ cảnh dự án có thể đọc bằng máy dành cho LLM được công bố trên website. + +> **Cloud / Marketplace:** [LangBot Space](https://space.langbot.app) cũng cung cấp MCP Server để agent tìm kiếm và kiểm tra marketplace plugin / MCP / skill, xác thực bằng Personal Access Token. + +--- + +## Cộng đồng + +[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&label=Discord)](https://discord.gg/wdNEHETs87) + +- [Cộng đồng Discord](https://discord.gg/wdNEHETs87) + +--- + +## Lịch sử Star + +[![Star History Chart](https://api.star-history.com/svg?repos=langbot-app/LangBot&type=Date)](https://star-history.com/#langbot-app/LangBot&Date) + +--- + +## Người đóng góp + +Cảm ơn tất cả [người đóng góp](https://github.com/langbot-app/LangBot/graphs/contributors) đã giúp LangBot trở nên tốt hơn: + + + + diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..5dd2178 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,4 @@ +coverage: + status: + project: off + patch: off \ No newline at end of file diff --git a/docker/deploy-k8s-test.sh b/docker/deploy-k8s-test.sh new file mode 100755 index 0000000..ff8e56e --- /dev/null +++ b/docker/deploy-k8s-test.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Quick test script for LangBot Kubernetes deployment +# This script helps you test the Kubernetes deployment locally + +set -e + +echo "🚀 LangBot Kubernetes Deployment Test Script" +echo "==============================================" +echo "" + +# Check for kubectl +if ! command -v kubectl &> /dev/null; then + echo "❌ kubectl is not installed. Please install kubectl first." + echo "Visit: https://kubernetes.io/docs/tasks/tools/" + exit 1 +fi + +echo "✓ kubectl is installed" + +# Check if kubectl can connect to a cluster +if ! kubectl cluster-info &> /dev/null; then + echo "" + echo "⚠️ No Kubernetes cluster found." + echo "" + echo "To test locally, you can use:" + echo " - kind: https://kind.sigs.k8s.io/" + echo " - minikube: https://minikube.sigs.k8s.io/" + echo " - k3s: https://k3s.io/" + echo "" + echo "Example with kind:" + echo " kind create cluster --name langbot-test" + echo "" + exit 1 +fi + +echo "✓ Connected to Kubernetes cluster" +kubectl cluster-info +echo "" + +# Ask user to confirm +read -p "Do you want to deploy LangBot to this cluster? (y/N) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Deployment cancelled." + exit 0 +fi + +echo "" +echo "📦 Deploying LangBot..." +kubectl apply -f kubernetes.yaml + +echo "" +echo "⏳ Waiting for pods to be ready..." +kubectl wait --for=condition=ready pod -l app=langbot -n langbot --timeout=300s +kubectl wait --for=condition=ready pod -l app=langbot-plugin-runtime -n langbot --timeout=300s + +echo "" +echo "✅ Deployment complete!" +echo "" +echo "📊 Deployment status:" +kubectl get all -n langbot + +echo "" +echo "🌐 To access LangBot Web UI, run:" +echo " kubectl port-forward -n langbot svc/langbot 5300:5300" +echo "" +echo "Then visit: http://localhost:5300" +echo "" +echo "📝 To view logs:" +echo " kubectl logs -n langbot -l app=langbot -f" +echo "" +echo "🗑️ To uninstall:" +echo " kubectl delete namespace langbot" +echo "" diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000..bdd3470 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,79 @@ +# Docker Compose configuration for LangBot +# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app +version: "3" + +services: + + langbot_plugin_runtime: + image: rockchin/langbot:latest + container_name: langbot_plugin_runtime + volumes: + - ./data/plugins:/app/data/plugins + ports: + - 5401:5401 + restart: on-failure + environment: + - TZ=Asia/Shanghai + command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"] + networks: + - langbot_network + + # The Box sandbox runtime is optional. It is only started when you run + # ``docker compose --profile box up`` (or ``docker compose --profile all + # up``). With Box off, LangBot keeps the dashboard / skills list visible + # (read-only) but disables sandbox tools, skill add/edit and stdio MCP — + # set ``box.enabled: false`` in ``data/config.yaml`` (or + # ``BOX__ENABLED=false`` in the langbot service env below) to match. + langbot_box: + image: rockchin/langbot:latest + container_name: langbot_box + profiles: ["box", "all"] + volumes: + # Keep the source and target path identical because langbot_box uses the + # host Docker socket to create sandbox containers. Override + # LANGBOT_BOX_ROOT with an absolute path if you do not want the default. + - ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box} + # Mount container runtime socket for Box sandbox backend. + # Uncomment the one that matches your container runtime: + # - /var/run/podman/podman.sock:/var/run/podman/podman.sock # Podman + - /var/run/docker.sock:/var/run/docker.sock # Docker + restart: on-failure + environment: + - TZ=Asia/Shanghai + # The Box runtime does NOT read box.local.* from config.yaml or env; it + # receives its configuration from LangBot via the INIT RPC action. + # Do not add LANGBOT_BOX_* / BOX__* here — they would be silently ignored. + # Launched through the same CLI entry point as the plugin runtime + # (`langbot_plugin.cli.__init__ `). WebSocket is the default + # control transport — mirrors `rt`, which also runs with no flag. Pass + # `-s` / `--stdio-control` only for the stdio mode LangBot uses outside + # containers. + command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"] + networks: + - langbot_network + + langbot: + image: rockchin/langbot:latest + container_name: langbot + volumes: + - ./data:/app/data + restart: on-failure + environment: + - TZ=Asia/Shanghai + # Unified env-override convention: SECTION__SUBSECTION__KEY overrides the + # matching config.yaml field (see LoadConfigStage). These map onto + # box.* and are forwarded to the Box runtime via INIT RPC. + - BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box} + - BOX__LOCAL__DEFAULT_WORKSPACE=default + - BOX__LOCAL__SKILLS_ROOT=skills + - BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box} + - BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true} + ports: + - 5300:5300 # For web ui and webhook callback + - 2280-2285:2280-2285 # For platform reverse connection + networks: + - langbot_network + +networks: + langbot_network: + driver: bridge diff --git a/docker/kubernetes.yaml b/docker/kubernetes.yaml new file mode 100644 index 0000000..6adc505 --- /dev/null +++ b/docker/kubernetes.yaml @@ -0,0 +1,571 @@ +# Kubernetes Deployment for LangBot +# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml +# +# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes +# +# Usage: +# kubectl apply -f kubernetes.yaml +# +# Prerequisites: +# - A Kubernetes cluster (1.19+) +# - kubectl configured to communicate with your cluster +# - (Optional) A StorageClass for dynamic volume provisioning +# - For the Box sandbox runtime: a node with a reachable Docker daemon +# (the box mounts the node's /var/run/docker.sock). See the deployment guide. +# +# Components: +# - Namespace: langbot +# - PersistentVolumeClaims for data persistence +# - Deployments for langbot, langbot-plugin-runtime, and langbot-box (sandbox) +# - Services for network access +# - ConfigMap for timezone + runtime endpoints + +--- +# Namespace +apiVersion: v1 +kind: Namespace +metadata: + name: langbot + labels: + app: langbot + +--- +# PersistentVolumeClaim for LangBot data +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: langbot-data + namespace: langbot +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + # Uncomment and modify if you have a specific StorageClass + # storageClassName: your-storage-class + +--- +# PersistentVolumeClaim for LangBot plugins +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: langbot-plugins + namespace: langbot +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + # Uncomment and modify if you have a specific StorageClass + # storageClassName: your-storage-class + +--- +# PersistentVolumeClaim for Plugin Runtime data +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: langbot-plugin-runtime-data + namespace: langbot +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + # Uncomment and modify if you have a specific StorageClass + # storageClassName: your-storage-class + +--- +# ConfigMap for environment configuration +apiVersion: v1 +kind: ConfigMap +metadata: + name: langbot-config + namespace: langbot +data: + TZ: "Asia/Shanghai" + PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws" + # Box sandbox runtime endpoint. LangBot connects to the Box runtime over + # WebSocket. The hostname MUST match the langbot-box Service name. Note the + # in-container default ("langbot_box") uses an underscore, which is an + # invalid Kubernetes DNS name — so the endpoint is always set explicitly here. + BOX__RUNTIME__ENDPOINT: "ws://langbot-box:5410" + +--- +# Deployment for LangBot Plugin Runtime +apiVersion: apps/v1 +kind: Deployment +metadata: + name: langbot-plugin-runtime + namespace: langbot + labels: + app: langbot-plugin-runtime +spec: + replicas: 1 + selector: + matchLabels: + app: langbot-plugin-runtime + template: + metadata: + labels: + app: langbot-plugin-runtime + spec: + containers: + - name: langbot-plugin-runtime + image: rockchin/langbot:latest + imagePullPolicy: Always + command: ["uv", "run", "-m", "langbot_plugin.cli.__init__", "rt"] + ports: + - containerPort: 5400 + name: runtime + protocol: TCP + env: + - name: TZ + valueFrom: + configMapKeyRef: + name: langbot-config + key: TZ + volumeMounts: + - name: plugin-data + mountPath: /app/data/plugins + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "2Gi" + cpu: "1000m" + # Liveness probe to restart container if it becomes unresponsive + livenessProbe: + tcpSocket: + port: 5400 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # Readiness probe to know when container is ready to accept traffic + readinessProbe: + tcpSocket: + port: 5400 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + volumes: + - name: plugin-data + persistentVolumeClaim: + claimName: langbot-plugin-runtime-data + restartPolicy: Always + +--- +# Service for LangBot Plugin Runtime +apiVersion: v1 +kind: Service +metadata: + name: langbot-plugin-runtime + namespace: langbot + labels: + app: langbot-plugin-runtime +spec: + type: ClusterIP + selector: + app: langbot-plugin-runtime + ports: + - port: 5400 + targetPort: 5400 + protocol: TCP + name: runtime + +--- +# Deployment for LangBot Box (sandbox) runtime +# +# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit / +# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP +# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on +# the langbot Deployment (or `box.enabled: false` in config.yaml) so the +# dashboard renders cleanly with sandbox features disabled. +# +# IMPORTANT — how the sandbox actually runs: +# The bundled image ships only the Docker CLI (no dockerd, no nsjail). The Box +# runtime therefore creates sandbox containers by talking to a Docker daemon +# over the mounted socket (`/var/run/docker.sock`). Because that daemon +# resolves bind-mount paths on the NODE filesystem, the Box workspace root +# must be the SAME absolute path inside the box container, inside every +# sandbox container it spawns, AND on the node. That is why this manifest uses +# a hostPath at a fixed absolute path (/app/data/box) and pins langbot + box +# to the same node via podAffinity. A normal PVC will NOT work for the box +# workspace, because the node's dockerd cannot see paths that exist only +# inside the pod's mount namespace. +# +# Security note: mounting the host Docker socket grants the Box runtime (and any +# code executed in the sandbox) effective root on the node. Only deploy Box on +# nodes you trust for this workload, ideally a dedicated node pool. For a +# stronger isolation boundary, switch box.backend to 'e2b' (set E2B_API_KEY) and +# drop the docker.sock mount + hostPath entirely. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: langbot-box + namespace: langbot + labels: + app: langbot-box +spec: + replicas: 1 + selector: + matchLabels: + app: langbot-box + template: + metadata: + labels: + app: langbot-box + spec: + # Pin to the same node as langbot so they share the hostPath box root. + affinity: + podAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + app: langbot + topologyKey: kubernetes.io/hostname + containers: + - name: langbot-box + image: rockchin/langbot:latest + imagePullPolicy: Always + # Launched through the same CLI entry point as the plugin runtime. + # No flag => WebSocket control transport (default), listening on 5410. + command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"] + ports: + - containerPort: 5410 + name: box-rpc + protocol: TCP + env: + - name: TZ + valueFrom: + configMapKeyRef: + name: langbot-config + key: TZ + # The Box runtime does NOT read box.local.* / BOX__* from its own env; + # it receives its configuration from LangBot via the INIT RPC action. + # Do not add BOX__* here — they would be silently ignored. + volumeMounts: + # Box workspace root — identical path on node, box, and sandbox + # containers (see the IMPORTANT note above). + - name: box-root + mountPath: /app/data/box + # Host Docker socket — the sandbox backend uses it to create containers. + - name: docker-sock + mountPath: /var/run/docker.sock + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + tcpSocket: + port: 5410 + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + tcpSocket: + port: 5410 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + volumes: + - name: box-root + hostPath: + path: /app/data/box + type: DirectoryOrCreate + - name: docker-sock + hostPath: + path: /var/run/docker.sock + type: Socket + restartPolicy: Always + +--- +# Service for LangBot Box runtime +apiVersion: v1 +kind: Service +metadata: + name: langbot-box + namespace: langbot + labels: + app: langbot-box +spec: + type: ClusterIP + selector: + app: langbot-box + ports: + - port: 5410 + targetPort: 5410 + protocol: TCP + name: box-rpc + +--- +# Deployment for LangBot +apiVersion: apps/v1 +kind: Deployment +metadata: + name: langbot + namespace: langbot + labels: + app: langbot +spec: + replicas: 1 + selector: + matchLabels: + app: langbot + template: + metadata: + labels: + app: langbot + spec: + containers: + - name: langbot + image: rockchin/langbot:latest + imagePullPolicy: Always + ports: + - containerPort: 5300 + name: web + protocol: TCP + - containerPort: 2280 + name: webhook-start + protocol: TCP + # Note: Kubernetes doesn't support port ranges directly in container ports + # The webhook ports 2280-2290 are available, but we only expose the start of the range + # If you need all ports exposed, consider using a Service with multiple port definitions + env: + - name: TZ + valueFrom: + configMapKeyRef: + name: langbot-config + key: TZ + - name: PLUGIN__RUNTIME_WS_URL + valueFrom: + configMapKeyRef: + name: langbot-config + key: PLUGIN__RUNTIME_WS_URL + # Box (sandbox) runtime endpoint. Connects LangBot to the langbot-box + # Service over WebSocket. Remove this (and the langbot-box Deployment) + # and set BOX__ENABLED=false if you do not want the sandbox. + - name: BOX__RUNTIME__ENDPOINT + valueFrom: + configMapKeyRef: + name: langbot-config + key: BOX__RUNTIME__ENDPOINT + # box.local.* config — forwarded to the Box runtime via INIT RPC. The + # host_root MUST match the box-root hostPath mountPath below AND the box + # Deployment's box-root mountPath, so that skill package paths resolve + # identically on both sides and on the node's Docker daemon. + - name: BOX__LOCAL__HOST_ROOT + value: "/app/data/box" + - name: BOX__LOCAL__DEFAULT_WORKSPACE + value: "default" + - name: BOX__LOCAL__SKILLS_ROOT + value: "skills" + - name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS + value: "/app/data/box" + volumeMounts: + - name: data + mountPath: /app/data + - name: plugins + mountPath: /app/plugins + # Same node-level box root as the langbot-box Deployment. Mounted over + # the data PVC's /app/data/box subpath so both LangBot and the Box + # runtime (and the node's dockerd) agree on one absolute path. + - name: box-root + mountPath: /app/data/box + resources: + requests: + memory: "1Gi" + cpu: "500m" + limits: + memory: "4Gi" + cpu: "2000m" + # Liveness probe to restart container if it becomes unresponsive + livenessProbe: + httpGet: + path: / + port: 5300 + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + # Readiness probe to know when container is ready to accept traffic + readinessProbe: + httpGet: + path: / + port: 5300 + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + volumes: + - name: data + persistentVolumeClaim: + claimName: langbot-data + - name: plugins + persistentVolumeClaim: + claimName: langbot-plugins + # Node-level box workspace root, shared with the langbot-box Deployment. + # hostPath (not PVC) because the node's Docker daemon must see the same + # absolute path when bind-mounting workspaces into sandbox containers. + - name: box-root + hostPath: + path: /app/data/box + type: DirectoryOrCreate + restartPolicy: Always + +--- +# Service for LangBot (ClusterIP for internal access) +apiVersion: v1 +kind: Service +metadata: + name: langbot + namespace: langbot + labels: + app: langbot +spec: + type: ClusterIP + selector: + app: langbot + ports: + - port: 5300 + targetPort: 5300 + protocol: TCP + name: web + - port: 2280 + targetPort: 2280 + protocol: TCP + name: webhook-2280 + - port: 2281 + targetPort: 2281 + protocol: TCP + name: webhook-2281 + - port: 2282 + targetPort: 2282 + protocol: TCP + name: webhook-2282 + - port: 2283 + targetPort: 2283 + protocol: TCP + name: webhook-2283 + - port: 2284 + targetPort: 2284 + protocol: TCP + name: webhook-2284 + - port: 2285 + targetPort: 2285 + protocol: TCP + name: webhook-2285 + - port: 2286 + targetPort: 2286 + protocol: TCP + name: webhook-2286 + - port: 2287 + targetPort: 2287 + protocol: TCP + name: webhook-2287 + - port: 2288 + targetPort: 2288 + protocol: TCP + name: webhook-2288 + - port: 2289 + targetPort: 2289 + protocol: TCP + name: webhook-2289 + - port: 2290 + targetPort: 2290 + protocol: TCP + name: webhook-2290 + +--- +# Ingress for external access (Optional - requires Ingress Controller) +# Uncomment and modify the following section if you want to expose LangBot via Ingress +# apiVersion: networking.k8s.io/v1 +# kind: Ingress +# metadata: +# name: langbot-ingress +# namespace: langbot +# annotations: +# # Uncomment and modify based on your ingress controller +# # nginx.ingress.kubernetes.io/rewrite-target: / +# # cert-manager.io/cluster-issuer: letsencrypt-prod +# spec: +# ingressClassName: nginx # Change based on your ingress controller +# rules: +# - host: langbot.yourdomain.com # Change to your domain +# http: +# paths: +# - path: / +# pathType: Prefix +# backend: +# service: +# name: langbot +# port: +# number: 5300 +# # Uncomment for TLS/HTTPS +# # tls: +# # - hosts: +# # - langbot.yourdomain.com +# # secretName: langbot-tls + +--- +# Service for LangBot with LoadBalancer (Alternative to Ingress) +# Uncomment the following if you want to expose LangBot directly via LoadBalancer +# This is useful in cloud environments (AWS, GCP, Azure, etc.) +# apiVersion: v1 +# kind: Service +# metadata: +# name: langbot-loadbalancer +# namespace: langbot +# labels: +# app: langbot +# spec: +# type: LoadBalancer +# selector: +# app: langbot +# ports: +# - port: 80 +# targetPort: 5300 +# protocol: TCP +# name: web +# - port: 2280 +# targetPort: 2280 +# protocol: TCP +# name: webhook-start +# # Add more webhook ports as needed + +--- +# Service for LangBot with NodePort (Alternative for exposing service) +# Uncomment if you want to expose LangBot via NodePort +# This is useful for testing or when LoadBalancer is not available +# apiVersion: v1 +# kind: Service +# metadata: +# name: langbot-nodeport +# namespace: langbot +# labels: +# app: langbot +# spec: +# type: NodePort +# selector: +# app: langbot +# ports: +# - port: 5300 +# targetPort: 5300 +# nodePort: 30300 # Must be in range 30000-32767 +# protocol: TCP +# name: web +# - port: 2280 +# targetPort: 2280 +# nodePort: 30280 # Must be in range 30000-32767 +# protocol: TCP +# name: webhook diff --git a/docs/API_KEY_AUTH.md b/docs/API_KEY_AUTH.md new file mode 100644 index 0000000..ad3bb66 --- /dev/null +++ b/docs/API_KEY_AUTH.md @@ -0,0 +1,323 @@ +# API Key Authentication + +LangBot now supports API key authentication for external systems to access its HTTP service API. + +## Managing API Keys + +API keys can be managed through the web interface: + +1. Log in to the LangBot web interface +2. Click the "API Keys" button at the bottom of the sidebar +3. Create, view, copy, or delete API keys as needed + +## Global API Key (config.yaml) + +In addition to web-UI-created keys (stored in the database, prefixed `lbk_`), +LangBot supports a **global API key** defined directly in `data/config.yaml`. +This is useful for automated deployments, infrastructure-as-code, and AI agents +that need API/MCP access **without a login session and without creating a +database record first**. + +```yaml +api: + port: 5300 + # ... + global_api_key: 'your-strong-secret-here' # leave empty to disable +``` + +Behavior: + +- When `api.global_api_key` is a non-empty string, that exact value is accepted + anywhere a normal API key is accepted — the `X-API-Key` header or + `Authorization: Bearer ` — across the HTTP service API **and the MCP + server**. +- The global key does **not** require the `lbk_` prefix; use any sufficiently + strong secret. +- Leave it empty (`''`, the default) to disable it entirely; only database-backed + `lbk_` keys will then be accepted. +- Existing installs are unaffected until you add the key — config completion only + backfills top-level keys, and the lookup is defensive when the field is absent. + +> **Security:** the global key is stored in plaintext in `config.yaml`. Only +> enable it on trusted/internal deployments, keep the file permissions tight, +> always serve over HTTPS, and rotate the value if it may have leaked. + +## Using API Keys + +### Authentication Headers + +Include your API key in the request header using one of these methods: + +**Method 1: X-API-Key header (Recommended)** +``` +X-API-Key: lbk_your_api_key_here +``` + +**Method 2: Authorization Bearer token** +``` +Authorization: Bearer lbk_your_api_key_here +``` + +## Available APIs + +All existing LangBot APIs now support **both user token and API key authentication**. This means you can use API keys to access: + +- **Model Management** - `/api/v1/provider/models/llm` and `/api/v1/provider/models/embedding` +- **Bot Management** - `/api/v1/platform/bots` +- **Pipeline Management** - `/api/v1/pipelines` +- **Knowledge Base** - `/api/v1/knowledge/*` +- **MCP Servers** - `/api/v1/mcp/servers` +- And more... + +### Authentication Methods + +Each endpoint accepts **either**: +1. **User Token** (via `Authorization: Bearer `) - for web UI and authenticated users +2. **API Key** (via `X-API-Key` or `Authorization: Bearer `) - for external services + +## Example: Model Management + +### List All LLM Models + +```http +GET /api/v1/provider/models/llm +X-API-Key: lbk_your_api_key_here +``` + +Response: +```json +{ + "code": 0, + "msg": "ok", + "data": { + "models": [ + { + "uuid": "model-uuid", + "name": "GPT-4", + "description": "OpenAI GPT-4 model", + "requester": "openai-chat-completions", + "requester_config": {...}, + "abilities": ["chat", "vision"], + "created_at": "2024-01-01T00:00:00", + "updated_at": "2024-01-01T00:00:00" + } + ] + } +} +``` + +### Create a New LLM Model + +```http +POST /api/v1/provider/models/llm +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "My Custom Model", + "description": "Description of the model", + "requester": "openai-chat-completions", + "requester_config": { + "model": "gpt-4", + "args": {} + }, + "api_keys": [ + { + "name": "default", + "keys": ["sk-..."] + } + ], + "abilities": ["chat"], + "extra_args": {} +} +``` + +### Update an LLM Model + +```http +PUT /api/v1/provider/models/llm/{model_uuid} +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "Updated Model Name", + "description": "Updated description", + ... +} +``` + +### Delete an LLM Model + +```http +DELETE /api/v1/provider/models/llm/{model_uuid} +X-API-Key: lbk_your_api_key_here +``` + +## Example: Bot Management + +### List All Bots + +```http +GET /api/v1/platform/bots +X-API-Key: lbk_your_api_key_here +``` + +### Create a New Bot + +```http +POST /api/v1/platform/bots +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "My Bot", + "adapter": "telegram", + "config": {...} +} +``` + +## Example: Pipeline Management + +### List All Pipelines + +```http +GET /api/v1/pipelines +X-API-Key: lbk_your_api_key_here +``` + +### Create a New Pipeline + +```http +POST /api/v1/pipelines +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "My Pipeline", + "config": {...} +} +``` + +## Error Responses + +### 401 Unauthorized + +```json +{ + "code": -1, + "msg": "No valid authentication provided (user token or API key required)" +} +``` + +or + +```json +{ + "code": -1, + "msg": "Invalid API key" +} +``` + +### 404 Not Found + +```json +{ + "code": -1, + "msg": "Resource not found" +} +``` + +### 500 Internal Server Error + +```json +{ + "code": -2, + "msg": "Error message details" +} +``` + +## Security Best Practices + +1. **Keep API keys secure**: Store them securely and never commit them to version control +2. **Use HTTPS**: Always use HTTPS in production to encrypt API key transmission +3. **Rotate keys regularly**: Create new API keys periodically and delete old ones +4. **Use descriptive names**: Give your API keys meaningful names to track their usage +5. **Delete unused keys**: Remove API keys that are no longer needed +6. **Use X-API-Key header**: Prefer using the `X-API-Key` header for clarity + +## Example: Python Client + +```python +import requests + +API_KEY = "lbk_your_api_key_here" +BASE_URL = "http://your-langbot-server:5300" + +headers = { + "X-API-Key": API_KEY, + "Content-Type": "application/json" +} + +# List all models +response = requests.get(f"{BASE_URL}/api/v1/provider/models/llm", headers=headers) +models = response.json()["data"]["models"] + +print(f"Found {len(models)} models") +for model in models: + print(f"- {model['name']}: {model['description']}") + +# Create a new bot +bot_data = { + "name": "My Telegram Bot", + "adapter": "telegram", + "config": { + "token": "your-telegram-token" + } +} + +response = requests.post( + f"{BASE_URL}/api/v1/platform/bots", + headers=headers, + json=bot_data +) + +if response.status_code == 200: + bot_uuid = response.json()["data"]["uuid"] + print(f"Bot created with UUID: {bot_uuid}") +``` + +## Example: cURL + +```bash +# List all models +curl -X GET \ + -H "X-API-Key: lbk_your_api_key_here" \ + http://your-langbot-server:5300/api/v1/provider/models/llm + +# Create a new pipeline +curl -X POST \ + -H "X-API-Key: lbk_your_api_key_here" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My Pipeline", + "config": {...} + }' \ + http://your-langbot-server:5300/api/v1/pipelines + +# Get bot logs +curl -X POST \ + -H "X-API-Key: lbk_your_api_key_here" \ + -H "Content-Type: application/json" \ + -d '{ + "from_index": -1, + "max_count": 10 + }' \ + http://your-langbot-server:5300/api/v1/platform/bots/{bot_uuid}/logs +``` + +## Notes + +- The same endpoints work for both the web UI (with user tokens) and external services (with API keys) +- No need to learn different API paths - use the existing API documentation with API key authentication +- All endpoints that previously required user authentication now also accept API keys + diff --git a/docs/HTTP_BOT_ADAPTER_DESIGN.md b/docs/HTTP_BOT_ADAPTER_DESIGN.md new file mode 100644 index 0000000..31e9a48 --- /dev/null +++ b/docs/HTTP_BOT_ADAPTER_DESIGN.md @@ -0,0 +1,575 @@ +# HTTP Bot Adapter — Design Document + +> Status: **Implemented** · Branch: `feat/http-bot-adapter` · Author: LangBot core +> +> A first-class, **standalone** message-platform adapter (`http_bot`) that lets +> any external system (e.g. LangBot Space ticketing, an internal back-office, a +> CRM, a custom web app) talk to a LangBot pipeline over plain HTTP — **inbound** +> by POSTing messages in, **outbound** by receiving replies on a callback URL — +> with full support for the pipeline's native N→1 aggregation and 1→M +> multi-reply semantics, and **without** holding a long-lived WebSocket +> connection. +> +> **Shipped in this branch:** +> - `src/langbot/pkg/platform/sources/http_bot.yaml` — adapter manifest (auto-discovered) +> - `src/langbot/pkg/platform/sources/http_bot.py` — `HttpBotAdapter` +> - `src/langbot/pkg/platform/sources/http_bot_signing.py` — HMAC helpers +> - `src/langbot/pkg/platform/sources/http_bot.svg` — icon +> - `docs/platforms/http-bot.md` — integration guide +> - `docs/http-bot-openapi.json` — machine-readable contract +> - `examples/http-bot/` — Python + TypeScript reference clients +> +> **Final decisions (resolving the original open questions):** +> 1. Callback URL is **config-only** — never accepted per-message (SSRF closed). +> 2. **Session reset is provided** — `POST /bots//reset` keyed by `session_id`. +> 3. Reference **clients are provided** — `examples/http-bot/client.py` + `client.ts`. +> 4. **Sync convenience mode is included** — `POST /bots//sync` (opt-in, lossy). + +--- + +## 1. Background & Motivation + +### 1.1 The concrete need + +LangBot Space wants to use a LangBot pipeline as the brain for **ticket +handling**. The integration is **server-to-server**: Space's backend pushes a +user's ticket messages into LangBot and renders LangBot's replies back into the +ticket thread. + +This interaction is **not** request/response shaped: + +- **N → 1**: a user may fire several messages in a row ("the app crashed" … + "when I click export" … "here's a screenshot"). The pipeline's + **message aggregation** feature should debounce and merge these into one turn. +- **1 → N**: a single turn may yield **multiple** outbound messages — a tool/ + function call narrating progress, a plugin emitting several cards, a streamed + answer split into chunks. + +### 1.2 Why the existing options don't fit + +LangBot today exposes exactly one externally-reachable way to drive a pipeline +that is **not** tied to a specific IM vendor: the **WebSocket** path +(`/api/v1/pipelines//ws/connect` for dashboard debug, and +`/api/v1/embed//ws/connect` for the embeddable web widget). + +For a server-to-server integration the WebSocket path has real friction: + +| Problem | Detail | +|---|---| +| Long-lived connection | Caller must maintain a socket, heartbeats, and reconnect logic for what is fundamentally a fire-and-collect workload. | +| Session identity | Inbound messages are keyed by the transient `connection_id` (`websocket_{connection_id}`); the caller **cannot supply a stable, business-meaningful session id** (e.g. a ticket number). Multi-ticket isolation is not expressible. | +| Auth mismatch | The debug socket is gated by the **dashboard JWT** (must not be handed to an external service); the embed socket is gated by **Cloudflare Turnstile** (a *browser* human-check that a backend cannot satisfy). Neither is a server-to-server credential. | +| In-memory, single-process state | Session history lives in process memory and is lost on restart. | + +> **Key realisation.** The N→1 / 1→M behaviour the caller wants is **not** +> provided by WebSocket — it is provided by the **pipeline** (aggregation + +> the adapter being free to call `reply_message` any number of times). It is +> therefore **transport-independent**. We can deliver the exact same semantics +> over a far lighter HTTP transport. + +### 1.3 Why a *new, standalone* adapter (not a refactor of an existing one) + +The brief is explicit: **do not reuse / fork an existing vendor adapter.** The +vendor adapters (`lark`, `wecom`, `qqofficial`, `slack`, …) carry vendor-specific +signature schemes, payload shapes, and message-segment mappings. Bending one of +them into a "generic" mode would couple a public integration surface to one +vendor's quirks and make the developer experience worse for everyone. + +Instead we ship `http_bot` as a clean, independent adapter whose **entire +contract is LangBot's own** — documented, versioned, and designed front-to-back +around *integrator* developer experience. + +--- + +## 2. Goals & Non-Goals + +### Goals + +- **G1** A standalone `http_bot` adapter, selectable like any other platform + adapter in the dashboard, with its own config schema and docs. +- **G2** **Inbound**: external systems POST messages to a stable LangBot URL, + carrying a **caller-defined `session_id`** that maps 1:1 to a LangBot session. +- **G3** **Outbound**: LangBot delivers each reply by POSTing to a + caller-configured **callback URL**; one turn may produce **many** callbacks. +- **G4** Preserve pipeline-native **N→1 aggregation** and **1→M multi-reply**. +- **G5** Server-to-server **auth**: shared-secret HMAC request signing both + directions (no JWT, no Turnstile, no long-lived socket). +- **G6** **Great DX**: copy-pasteable curl, a tiny reference client, an OpenAPI + fragment, idempotency, clear error envelope, and a local echo-server recipe. + +### Non-Goals + +- Not replacing or deprecating the WebSocket / embed widget path (that remains + the right tool for *browser*, real-time, streaming chat UIs). +- Not a synchronous "one request → one response" RPC (explicitly rejected: it + cannot express 1→M; see §9 for the optional sync convenience mode). +- No built-in message **persistence/replay** in v1 (callbacks are at-least-once + best-effort; durability is the caller's responsibility — see §8). +- No multi-tenant API-key management UI in v1 (one secret per bot; see §11). + +--- + +## 3. How LangBot routes a message (the parts we plug into) + +Understanding the existing flow is what makes this adapter cheap. A message +flows through these stages (verified against current `master`): + +``` + INBOUND OUTBOUND + external POST ─┐ ┌─ reply_message() + ▼ │ reply_message_chunk() + POST /bots/ (unified webhook router, AuthType.NONE) + │ webhooks.py → adapter.handle_unified_webhook(bot_uuid, path, request) + ▼ │ + HttpBotAdapter.handle_unified_webhook │ (called 0..N times + • verify HMAC signature │ per turn by the + • parse {session_id, message[]} │ pipeline / plugins) + • build FriendMessage / GroupMessage │ + • fire registered listener ───────────────┐ │ + │ │ │ + ▼ ▼ │ + botmgr.on_friend_message / on_group_message │ + • (optional) webhook_pusher fan-out │ + • msg_aggregator.add_message(...) ── N→1 debounce ──►│ + │ │ + ▼ │ + query_pool → pipeline.run() ─── invokes adapter ─────┘ + reply methods 1..M times +``` + +Two framework facts we rely on: + +1. **N→1 aggregation is free.** `botmgr` hands every inbound event to + `self.ap.msg_aggregator.add_message(...)`, which debounces per + `session_id` and merges consecutive messages into one pipeline turn + (`pkg/pipeline/aggregator.py`). The adapter does nothing special. + +2. **1→M is free.** The pipeline (and any plugin in the chain) calls + `adapter.reply_message()` / `reply_message_chunk()` **as many times as it + wants** per turn. The adapter's only job is to deliver each call outward. + For `http_bot` that means: **one outbound callback POST per call.** + +3. **A unified inbound route already exists.** `WebhookRouterGroup` + (`pkg/api/http/controller/groups/webhooks.py`) maps + `POST /bots/[/]` (auth `NONE`) to + `adapter.handle_unified_webhook(bot_uuid, path, request)`. `http_bot` + implements that method and is reachable **without registering any new + route** — it does its own signature verification, exactly like the vendor + webhook adapters do. + +> Net new code is essentially: one `http_bot.py` adapter, one `http_bot.yaml` +> schema, signing helpers, and docs. No router, aggregator, or pipeline changes. + +--- + +## 4. Architecture Overview + +``` +┌────────────────────┐ (1) inbound: POST signed message +│ External system │ ──────────────────────────────────────────────► ┌──────────────────────┐ +│ (LangBot Space, │ POST /bots/ │ LangBot │ +│ CRM, web app …) │ X-LB-Signature, X-LB-Timestamp │ │ +│ │ { session_id, message:[...] } │ HttpBotAdapter │ +│ - callback server │ ◄────────────────────────────────────────────── │ (platform/sources) │ +│ (receives │ (4) outbound: POST signed reply(s) │ │ +│ replies) │ POST │ pipeline + aggregator│ +└────────────────────┘ X-LB-Signature, X-LB-Timestamp └──────────────────────┘ + { session_id, sequence, is_final, + message:[...] } (sent 1..M times) +``` + +- The adapter is **stateless across requests** at the HTTP layer; session + continuity is carried by `session_id` and resolved by LangBot's normal + session manager. +- **Inbound** and **outbound** are **independent HTTP exchanges**. LangBot does + not answer the inbound POST with the pipeline result; it `202 Accepts` it and + later POSTs the reply(s) to the callback URL. This is what makes 1→M natural. + +--- + +## 5. Configuration Schema (`http_bot.yaml`) + +Follows the existing `MessagePlatformAdapter` manifest convention (cf. +`slack.yaml`). Fields: + +| field | type | required | purpose | +|---|---|---|---| +| `inbound_secret` | string (secret) | yes | HMAC key the **caller** uses to sign inbound POSTs; LangBot verifies. | +| `callback_url` | string (url) | no* | Where LangBot POSTs replies. *Optional if the caller supplies `callback_url` per-message (see §6.1); a static default lives here. | +| `outbound_secret` | string (secret) | no | HMAC key LangBot uses to sign outbound callbacks; caller verifies. Defaults to `inbound_secret` if empty. | +| `default_session_type` | enum `person`/`group` | no | Default when a message omits `session_type`. Default `person`. | +| `signature_required` | bool | no | If `false`, skip inbound signature check (dev only; logs a warning). Default `true`. | +| `callback_timeout` | int (seconds) | no | Per-callback HTTP timeout. Default `15`. | +| `callback_max_retries` | int | no | Retries on 5xx/timeout with backoff. Default `3`. | +| `webhook_url` | webhook-url (display) | — | Read-only field rendering the inbound URL `…/bots/` for copy-paste, like other webhook adapters. | + +Manifest sketch (i18n labels elided for brevity): + +```yaml +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: http_bot + label: { en_US: "HTTP Bot", zh_Hans: "HTTP 通用接入" } + description: + en_US: "Integrate any backend over plain HTTP. Push messages in, receive replies on a callback URL. Server-to-server, no long-lived connection." + zh_Hans: "通过 HTTP 接入任意后端系统。推入消息、在回调地址接收回复。面向服务间集成,无需长连接。" + icon: http_bot.svg +spec: + categories: [popular, global] + help_links: + zh: https://docs.langbot.app/zh/platforms/http-bot + en: https://docs.langbot.app/en/platforms/http-bot + config: + - { name: inbound_secret, type: string, required: true, default: "" } + - { name: callback_url, type: string, required: false, default: "" } + - { name: outbound_secret, type: string, required: false, default: "" } + - { name: default_session_type, type: select, required: false, default: "person", + options: [person, group] } + - { name: signature_required, type: boolean, required: false, default: true } + - { name: callback_timeout, type: integer, required: false, default: 15 } + - { name: callback_max_retries, type: integer, required: false, default: 3 } + - { name: webhook_url, type: webhook-url, required: false, default: "" } +execution: + python: + path: ./http_bot.py + attr: HttpBotAdapter +``` + +--- + +## 6. The HTTP Contract (this is the DX surface) + +### 6.1 Inbound — push a message into LangBot + +``` +POST /bots/{bot_uuid} +Content-Type: application/json +X-LB-Timestamp: 1718000000 +X-LB-Signature: sha256= +X-LB-Idempotency-Key: # optional, dedup window +``` + +Body: + +```jsonc +{ + "session_id": "ticket-10293", // REQUIRED. Caller-defined. Maps 1:1 to a LangBot session. + "session_type": "person", // optional, "person" | "group"; default from config + "sender": { // optional metadata, surfaced to pipeline/plugins + "id": "user-5567", + "name": "Alice" + }, + "message": [ // REQUIRED. A LangBot MessageChain (list of segments). + { "type": "Plain", "text": "Export keeps failing on the dashboard." }, + { "type": "Image", "url": "https://.../screenshot.png" } + ] +} +``` + +Response (LangBot does **not** block on the pipeline): + +```jsonc +// 202 Accepted +{ + "code": 0, + "msg": "accepted", + "data": { + "session_id": "ticket-10293", + "accepted_message_id": "in_01H....", // server-assigned id for this inbound message + "aggregating": true // true if buffered by the aggregator + } +} +``` + +**N→1 in practice.** Fire three POSTs with the same `session_id` inside the +aggregation window → the pipeline runs **once** with the three messages merged. +No special flag needed; this is the aggregator's default behaviour when enabled +on the pipeline. + +### 6.2 Outbound — LangBot delivers replies to your callback + +For each `reply_message` / `reply_message_chunk` the pipeline emits, LangBot +POSTs to `callback_url`: + +``` +POST {callback_url} +Content-Type: application/json +X-LB-Timestamp: 1718000001 +X-LB-Signature: sha256= +``` + +Body: + +```jsonc +{ + "session_id": "ticket-10293", // echoes the inbound session + "reply_to": "in_01H....", // the inbound message id this answers + "sequence": 1, // 1-based ordinal within this turn (for 1→M ordering) + "is_final": false, // false for intermediate/streamed parts + "stream": false, // true when this is a streamed chunk + "message": [ + { "type": "Plain", "text": "Looking into it — checking your export logs…" } + ], + "timestamp": "2026-06-22T09:00:01Z" +} +``` + +**1→M in practice.** A turn that fires a function call then a final answer +produces e.g.: + +``` +POST callback → { sequence: 1, is_final: false, message: ["Checking logs…"] } +POST callback → { sequence: 2, is_final: false, message: ["Found 2 failed exports."] } +POST callback → { sequence: 3, is_final: true, message: ["Fixed. Try again now."] } +``` + +The caller stitches by `session_id` + `sequence`, and knows the turn is complete +when `is_final: true` arrives. + +Your callback endpoint should return `200` quickly. A non-2xx triggers retry +with backoff (`callback_max_retries`). + +### 6.3 Error envelope (inbound) + +Consistent, machine-readable; never leak internals: + +```jsonc +{ "code": 40101, "msg": "invalid signature", "data": null } +``` + +| HTTP | code | meaning | +|---|---|---| +| 202 | 0 | accepted | +| 400 | 40001 | malformed body / missing `session_id` or `message` | +| 401 | 40101 | bad/expired signature | +| 403 | 40301 | bot disabled | +| 404 | 40401 | bot_uuid not found / not an `http_bot` adapter | +| 409 | 40901 | duplicate idempotency key (already accepted) | +| 413 | 41301 | message too large | +| 500 | 50001 | internal error | + +--- + +## 7. Signing scheme (both directions) + +Symmetric, dependency-free HMAC-SHA256 — trivial to implement in any language. + +``` +signing_string = "{timestamp}.{raw_request_body}" +signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string)) +``` + +Verification rules: + +- Reject if `|now - timestamp| > 300s` (replay window). +- Constant-time compare (`hmac.compare_digest`). +- Inbound verified with `inbound_secret`; outbound signed with + `outbound_secret` (falls back to `inbound_secret`). +- `signature_required: false` bypasses verification **and logs a warning** — + intended only for local development behind a trusted network. + +Reference (Python, ~6 lines): + +```python +import hmac, hashlib, time + +def sign(secret: str, body: bytes, ts: int | None = None) -> tuple[str, str]: + ts = ts or int(time.time()) + mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256) + return str(ts), "sha256=" + mac.hexdigest() +``` + +--- + +## 8. Delivery semantics & reliability + +- **Inbound**: `202 Accepted` means *queued*, not *processed*. Use + `X-LB-Idempotency-Key` to make client retries safe (dedup window, e.g. 10 min). +- **Outbound**: **at-least-once**, best-effort. Retries on timeout/5xx with + exponential backoff up to `callback_max_retries`. Callbacks for one + `session_id` are delivered **in `sequence` order** (serialised per session); + across sessions they may interleave. +- **No persistence in v1**: if LangBot restarts mid-turn, in-flight callbacks + may be lost. Durable replay is deferred (see §13). Callers needing exactly-once + should dedup on `(session_id, reply_to, sequence)`. +- **Backpressure**: the adapter must not block the pipeline on slow callbacks — + outbound POSTs run on a per-session ordered queue with the configured timeout. + +--- + +## 9. Optional: synchronous convenience mode (v1.1, behind a flag) + +Some simple callers genuinely want "POST a message, get the reply in the HTTP +response" and don't care about streaming/multi-part. We can offer an **opt-in** +sync endpoint that internally waits for `is_final` and **collapses** all 1→M +parts into one array: + +``` +POST /bots/{bot_uuid}/sync → 200 { session_id, message: [ ...all parts concatenated... ] } +``` + +Implemented by attaching a per-request future that resolves on the final reply, +with a hard timeout. This is a **convenience wrapper** over the same machinery, +explicitly documented as lossy for streaming/ordering. Not in v1 core. + +--- + +## 10. Adapter implementation sketch (`platform/sources/http_bot.py`) + +Implements `AbstractMessagePlatformAdapter`. Key methods: + +```python +class HttpBotAdapter(AbstractMessagePlatformAdapter): + listeners: dict = pydantic.Field(default_factory=dict, exclude=True) + + # --- inbound ------------------------------------------------------- + async def handle_unified_webhook(self, bot_uuid, path, request): + body = await request.get_body() + if self.config.get("signature_required", True): + if not self._verify(request, body): + return jsonify({"code": 40101, "msg": "invalid signature"}), 401 + data = json.loads(body) + session_id = data["session_id"] # caller-defined identity + session_type = data.get("session_type", self.config.get("default_session_type", "person")) + chain = MessageChain.model_validate(data["message"]) + event = self._build_event(session_type, session_id, data.get("sender"), chain) + # remember where to send replies for this session + self._callback_for[session_id] = data.get("callback_url") or self.config.get("callback_url") + # fire the registered listener → botmgr → msg_aggregator (N→1) → pipeline + if type(event) in self.listeners: + asyncio.create_task(self.listeners[type(event)](event, self)) + return jsonify({"code": 0, "msg": "accepted", + "data": {"session_id": session_id, "accepted_message_id": event.message_id}}), 202 + + # --- outbound (called 1..M times per turn by the pipeline) --------- + async def reply_message(self, message_source, message, quote_origin=False): + return await self._post_callback(message_source, message, is_final=True, stream=False) + + async def reply_message_chunk(self, message_source, bot_message, message, + quote_origin=False, is_final=False): + return await self._post_callback(message_source, message, is_final=is_final, stream=True) + + async def is_stream_output_supported(self) -> bool: + return True + + def register_listener(self, event_type, func): self.listeners[event_type] = func + def unregister_listener(self, event_type, func): self.listeners.pop(event_type, None) + async def run_async(self): pass # nothing to poll; purely webhook-driven + async def kill(self): pass +``` + +`_post_callback` resolves the session's callback URL, assigns the next +`sequence`, signs the body, and enqueues an ordered, retrying POST. + +Session→callback mapping is kept in a small in-memory dict keyed by +`session_id` (acceptable for v1; a turn's callback URL is captured at inbound +time so replies always have a destination even if config later changes). + +--- + +## 11. Security considerations + +- **Inbound route is `AuthType.NONE`** at the framework level (same as all + webhook adapters) — the adapter **must** enforce HMAC itself. Default + `signature_required: true`. +- **Timestamp window** (±300s) + idempotency key blunt replay. +- **SSRF on callback_url**: validate scheme (`https` in prod), and consider an + allow-list / block of private CIDRs since LangBot initiates the POST. Document + this; enforce in code where feasible. +- **Secret storage**: secrets live in the bot's `adapter_config` like every + other adapter credential; surfaced as `type: string`/secret in the dashboard. +- **One secret per bot** in v1. Per-caller key rotation / multiple keys is a + future enhancement (§13). + +--- + +## 12. Developer Experience (explicit deliverables) + +The whole point of a standalone adapter is that **integrating is pleasant**. v1 +ships: + +1. **`docs/platforms/http-bot.md`** — task-oriented integration guide: + create the bot → copy inbound URL → set secret → stand up a callback + endpoint → send first message → handle 1→M. +2. **Copy-paste curl** for the first message (with a working signing one-liner). +3. **Reference clients** (≤50 LOC each) in `examples/http-bot/`: + `client.py` (push + a Flask/Quart callback receiver) and `client.ts`. +4. **OpenAPI fragment** `docs/http-bot-openapi.json` describing inbound + + callback shapes, so integrators can codegen. +5. **Local echo recipe**: a one-command callback server that prints every + reply, so a developer sees N→1 and 1→M working in under five minutes. +6. **Postman/Hoppscotch collection** (nice-to-have). + +DX acceptance check: *a developer who has never seen LangBot can, from the docs +alone, push a message and observe a multi-part reply on their callback within +10 minutes.* + +### Quickstart (curl) + +```bash +BOT=https://your-langbot/bots/2f1c.... +SECRET=supersecret +BODY='{"session_id":"ticket-10293","message":[{"type":"Plain","text":"hello"}]}' +TS=$(date +%s) +SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)" +curl -sS -X POST "$BOT" \ + -H "Content-Type: application/json" \ + -H "X-LB-Timestamp: $TS" \ + -H "X-LB-Signature: $SIG" \ + -d "$BODY" +``` + +--- + +## 13. Future work + +- **Durable outbound queue** (persist + replay across restarts; exactly-once). +- **Per-caller API keys** with rotation and scopes (multi-tenant Space usage). +- **Sync convenience endpoint** (§9) once core is stable. +- **Server-Sent Events outbound option** for callers that *do* want a stream but + not a full duplex socket — single GET, server pushes chunks. +- **Dashboard "test console"** for `http_bot` (send a message, watch callbacks) + mirroring the existing WebSocket debug panel. + +--- + +## 14. Rollout / task breakdown + +| # | Task | Touches | +|---|---|---| +| 1 | `http_bot.yaml` manifest + icon | `platform/sources/` | +| 2 | `HttpBotAdapter` (inbound verify, event build, outbound queue) | `platform/sources/http_bot.py` | +| 3 | Signing helper module (shared) | `platform/sources/` or `utils/` | +| 4 | i18n strings (en/zh/ja) | adapter yaml + web locale | +| 5 | Integration docs `docs/platforms/http-bot.md` | `docs/` | +| 6 | OpenAPI fragment + reference clients | `docs/`, `examples/http-bot/` | +| 7 | Tests: signature verify, N→1 aggregation, 1→M ordering, retry | `tests/` | +| 8 | (opt) SSRF guard for callback_url | adapter | + +No changes required to: the unified webhook router, the aggregator, the query +pool, or the pipeline. That is the design's main payoff. + +--- + +## 15. Resolved decisions + +1. **Callback URL trust** — **config-only.** The inbound message may not carry a + `callback_url`; replies always go to the bot-config URL. Closes the SSRF + vector where a leaked inbound secret could redirect replies. +2. **Session lifecycle** — **`POST /bots//reset`** (body `{session_id, + session_type?}`) drops the matching session from the session manager; the + next message starts a fresh conversation. Implemented via sub-path routing in + `handle_unified_webhook`. +3. **Group semantics** — for `session_type: group`, `session_id` is the group/ + launcher id; `sender.id` (and optional `sender.group_name`) identify the + member. A Space ticket maps to one `session_id`. +4. **Backpressure** — bounded per-session outbound queue (maxlen 1000); on + overflow the oldest reply is dropped and a warning logged, so a persistently + down callback can never exhaust memory. + +### Still open / deferred (see §13) + +- Durable outbound queue (persist + replay across restarts). +- Per-caller API keys with rotation/scopes for multi-tenant Space usage. +- SSE outbound option and a dashboard test console. diff --git a/docs/MIGRATION_SUMMARY.md b/docs/MIGRATION_SUMMARY.md new file mode 100644 index 0000000..c038d04 --- /dev/null +++ b/docs/MIGRATION_SUMMARY.md @@ -0,0 +1,412 @@ +# WebChat 到 WebSocket 迁移总结 + +## 概述 + +已完全移除旧的基于SSE的WebChat系统,并替换为基于WebSocket的双向实时通信系统。这是一个内置在LangBot中的完整IM系统,支持流式输出。 + +## 已删除的文件 + +### 后端 +- ❌ `src/langbot/pkg/api/http/controller/groups/pipelines/webchat.py` - 旧的SSE路由 +- ❌ `src/langbot/pkg/platform/sources/webchat.py` - 旧的WebChat适配器 +- ❌ `src/langbot/pkg/platform/sources/webchat.yaml` - 旧的配置文件 + +### 前端 +- ❌ BackendClient中所有SSE相关代码已完全移除 +- ❌ DebugDialog中所有SSE相关逻辑已完全替换 + +## 新增的文件 + +### 后端核心文件 + +**1. WebSocket连接管理器** +``` +src/langbot/pkg/platform/sources/websocket_manager.py +``` +- 管理所有并发WebSocket连接 +- 线程安全的连接池 +- 按流水线、会话类型分组 +- 广播和单播消息功能 +- 连接统计和监控 + +**2. WebSocket适配器** +``` +src/langbot/pkg/platform/sources/websocket_adapter.py +``` +- 实现平台适配器接口 +- **完整流式支持** (`reply_message_chunk` 方法) +- 双向消息流处理 +- 消息历史管理 +- 会话管理 + +**3. WebSocket路由控制器** +``` +src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py +``` +- WebSocket端点处理 +- REST API接口 +- 心跳机制 +- 连接生命周期管理 + +**4. 配置文件** +``` +src/langbot/pkg/platform/sources/websocket.yaml +``` +- WebSocket适配器元数据 + +### 前端核心文件 + +**1. WebSocket客户端** +``` +web/src/app/infra/websocket/WebSocketClient.ts +``` +- WebSocket连接管理 +- 自动重连(最多5次) +- 心跳机制(30秒) +- 事件回调系统 + +**2. 更新的组件** +``` +web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx +``` +- 完全重写,使用WebSocket +- 实时连接状态显示 +- 流式消息支持 +- 自动重连 + +**3. HTTP客户端更新** +``` +web/src/app/infra/http/BackendClient.ts +``` +- 移除所有旧的WebChat API +- 仅保留WebSocket API + +### 测试工具 + +**Python测试客户端** +``` +test_websocket_client.py +``` +- 单连接交互测试 +- 多连接并发测试 +- 命令行工具 + +### 文档 + +**使用文档** +``` +WEBSOCKET_README.md +``` +- 完整的API文档 +- 架构说明 +- 使用示例 +- 故障排查 + +## 核心变更 + +### 后端变更 + +**1. botmgr.py** +- ❌ 移除 `webchat_proxy_bot` +- ✅ 仅保留 `websocket_proxy_bot` +- ✅ 更新适配器过滤逻辑(排除`websocket`而非`webchat`) + +**2. 适配器注册** +```python +# 旧代码(已删除) +webchat_adapter_class = self.adapter_dict['webchat'] +self.webchat_proxy_bot = RuntimeBot(...) + +# 新代码 +websocket_adapter_class = self.adapter_dict['websocket'] +self.websocket_proxy_bot = RuntimeBot( + uuid='websocket-proxy-bot', + name='WebSocket', + adapter='websocket', + ... +) +``` + +### 前端变更 + +**1. API调用完全更换** + +旧代码(已删除): +```typescript +// SSE流式请求 +await fetch(url, { + method: 'POST', + body: JSON.stringify({ is_stream: true }) +}) +// 手动解析 text/event-stream +``` + +新代码: +```typescript +// WebSocket实时通信 +const wsClient = new WebSocketClient(pipelineId, sessionType); +await wsClient.connect(); + +wsClient.onMessage((message) => { + // 流式消息自动处理 + setMessages(prev => [...prev, message]); +}); + +wsClient.sendMessage(messageChain); +``` + +**2. 连接状态管理** + +新增功能: +- ✅ 实时连接状态指示器(绿色/红色圆点) +- ✅ 连接/断开toast提示 +- ✅ 自动重连逻辑 +- ✅ 心跳保活 + +**3. 流式支持** + +完整的流式消息处理: +```typescript +wsClient.onMessage((message) => { + if (message.is_final) { + // 最终消息 + finalizeBotMessage(message); + } else { + // 中间消息块,实时更新UI + updateBotMessage(message); + } +}); +``` + +## API对比 + +### WebSocket端点 + +**连接** +``` +ws://localhost:8000/api/v1/pipelines//ws/connect?session_type= +``` + +**消息格式** + +客户端发送: +```json +{ + "type": "message", + "message": [ + {"type": "Plain", "text": "你好"} + ] +} +``` + +服务器响应(流式): +```json +{ + "type": "response", + "data": { + "id": 1, + "role": "assistant", + "content": "你好,我是...", + "is_final": false, + "timestamp": "2025-01-28T..." + } +} +``` + +### REST API + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/v1/pipelines//ws/messages/` | GET | 获取消息历史 | +| `/api/v1/pipelines//ws/reset/` | POST | 重置会话 | +| `/api/v1/pipelines//ws/connections` | GET | 获取连接统计 | +| `/api/v1/pipelines//ws/broadcast` | POST | 广播消息 | + +## 流式支持详解 + +### 后端流式实现 + +**WebSocket Adapter** +```python +async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, +) -> dict: + """回复消息块 - 流式""" + message_data = WebSocketMessage( + id=-1, + role='assistant', + content=str(message), + message_chain=[component.__dict__ for component in message], + timestamp=datetime.now().isoformat(), + is_final=is_final and bot_message.tool_calls is None, + ) + + # 发送到队列,由WebSocket连接处理发送 + await session.resp_queues[message_id].put(message_data) + return message_data.model_dump() + +async def is_stream_output_supported(self) -> bool: + """WebSocket始终支持流式输出""" + return True +``` + +### 前端流式处理 + +**DebugDialog组件** +```typescript +wsClient.onMessage((message) => { + setMessages((prevMessages) => { + const existingIndex = prevMessages.findIndex( + (msg) => msg.role === 'assistant' && msg.content === 'Generating...' + ); + + if (existingIndex !== -1) { + // 更新正在生成的消息 + const updatedMessages = [...prevMessages]; + updatedMessages[existingIndex] = message; + return updatedMessages; + } else { + // 添加新消息 + return [...prevMessages, message]; + } + }); +}); +``` + +## 兼容性说明 + +### ⚠️ 不兼容旧版本 + +此次迁移**完全不兼容**旧的WebChat系统: + +1. **API端点变更** + - 旧: `/api/v1/pipelines//chat/send` + - 新: `ws://...//ws/connect` + +2. **通信协议变更** + - 旧: HTTP + SSE (Server-Sent Events) + - 新: WebSocket (双向) + +3. **流式实现变更** + - 旧: `text/event-stream` 格式 + - 新: WebSocket JSON消息 + +### 迁移要求 + +使用新系统需要: +1. ✅ 前端必须支持WebSocket +2. ✅ 后端必须运行新的WebSocket适配器 +3. ✅ 清除旧的WebChat相关配置 + +## 优势对比 + +| 特性 | 旧WebChat (SSE) | 新WebSocket | +|------|----------------|-------------| +| 双向通信 | ❌ 单向(服务器→客户端) | ✅ 双向 | +| 主动推送 | ❌ 不支持 | ✅ 支持 | +| 连接管理 | ❌ 无状态 | ✅ 有状态,完整生命周期 | +| 流式输出 | ✅ 支持 | ✅ 支持(更优) | +| 心跳机制 | ❌ 无 | ✅ 30秒心跳 | +| 自动重连 | ❌ 无 | ✅ 最多5次 | +| 多连接 | ⚠️ 难以管理 | ✅ 完整支持 | +| 连接状态 | ❌ 不可见 | ✅ 实时显示 | +| 广播功能 | ❌ 不支持 | ✅ 支持 | + +## 测试方式 + +### 1. Python测试客户端 + +```bash +# 单连接测试 +python test_websocket_client.py + +# 指定会话类型 +python test_websocket_client.py --session-type group + +# 多连接并发测试(5个连接) +python test_websocket_client.py --multi 5 +``` + +### 2. 前端测试 + +1. 启动LangBot服务器 +2. 访问前端界面 +3. 打开流水线调试对话框 +4. 观察连接状态指示器(左下角圆点) +5. 发送消息测试流式响应 + +### 3. 浏览器控制台测试 + +```javascript +const ws = new WebSocket('ws://localhost:8000/api/v1/pipelines//ws/connect?session_type=person'); + +ws.onopen = () => { + console.log('已连接'); + ws.send(JSON.stringify({ + type: 'message', + message: [{type: 'Plain', text: '你好'}] + })); +}; + +ws.onmessage = (event) => { + console.log('收到:', JSON.parse(event.data)); +}; +``` + +## 常见问题 + +### Q: 为什么完全删除旧代码而不保留兼容性? +A: 根据需求,不需要考虑任何对老版本的兼容性,彻底迁移可以避免代码冗余和维护负担。 + +### Q: 流式输出如何工作? +A: +1. 后端通过`reply_message_chunk`发送消息块 +2. 消息块放入队列 +3. WebSocket连接从队列取出并发送 +4. 前端实时更新UI +5. `is_final=true`表示最后一块 + +### Q: 如何确保连接不断开? +A: +1. 客户端每30秒发送心跳(ping) +2. 服务器响应pong +3. 连接断开时自动重连(最多5次) + +### Q: 如何实现后端主动推送? +A: +1. 调用 `/api/v1/pipelines//ws/broadcast` API +2. 消息会被推送到该流水线的所有连接 +3. 前端通过`onBroadcast`回调接收 + +## 总结 + +✅ **完成的工作** +- 完全移除旧的WebChat/SSE系统 +- 实现完整的WebSocket双向通信系统 +- 支持流式输出 +- 支持多连接并发 +- 实现自动重连和心跳机制 +- 提供完整的测试工具和文档 + +✅ **核心特性** +- 双向实时通信 +- 流式消息支持 +- 多连接管理 +- 自动重连 +- 心跳保活 +- 连接状态可视化 +- 广播消息 + +✅ **技术亮点** +- 异步架构(asyncio) +- 线程安全的连接管理 +- 独立的消息队列 +- 完整的错误处理 +- 模块化设计 + +🎉 系统已完全迁移到WebSocket,无任何旧代码遗留! diff --git a/docs/PYPI_INSTALLATION.md b/docs/PYPI_INSTALLATION.md new file mode 100644 index 0000000..1144d5c --- /dev/null +++ b/docs/PYPI_INSTALLATION.md @@ -0,0 +1,117 @@ +# LangBot PyPI Package Installation + +## Quick Start with uvx + +The easiest way to run LangBot is using `uvx` (recommended for quick testing): + +```bash +uvx langbot +``` + +This will automatically download and run the latest version of LangBot. + +## Install with pip/uv + +You can also install LangBot as a regular Python package: + +```bash +# Using pip +pip install langbot + +# Using uv +uv pip install langbot +``` + +Then run it: + +```bash +langbot +``` + +Or using Python module syntax: + +```bash +python -m langbot +``` + +## Installation with Frontend + +When published to PyPI, the LangBot package includes the pre-built frontend files. You don't need to build the frontend separately. + +## Data Directory + +When running LangBot as a package, it will create a `data/` directory in your current working directory to store configuration, logs, and other runtime data. You can run LangBot from any directory, and it will set up its data directory there. + +## Command Line Options + +LangBot supports the following command line options: + +- `--standalone-runtime`: Use standalone plugin runtime +- `--debug`: Enable debug mode + +Example: + +```bash +langbot --debug +``` + +## Comparison with Other Installation Methods + +### PyPI Package (uvx/pip) +- **Pros**: Easy to install and update, no need to clone repository or build frontend +- **Cons**: Less flexible for development/customization + +### Docker +- **Pros**: Isolated environment, easy deployment +- **Cons**: Requires Docker + +### Manual Source Installation +- **Pros**: Full control, easy to customize and develop +- **Cons**: Requires building frontend, managing dependencies manually + +## Development + +If you want to contribute or customize LangBot, you should still use the manual installation method by cloning the repository: + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot +uv sync +cd web +npm install +npm run build +cd .. +uv run main.py +``` + +## Updating + +To update to the latest version: + +```bash +# With pip +pip install --upgrade langbot + +# With uv +uv pip install --upgrade langbot + +# With uvx (automatically uses latest) +uvx langbot +``` + +## System Requirements + +- Python 3.10.1 or higher +- Operating System: Linux, macOS, or Windows + +## Differences from Source Installation + +When running LangBot from the PyPI package (via uvx or pip), there are a few behavioral differences compared to running from source: + +1. **Version Check**: The package version does not prompt for user input when the Python version is incompatible. It simply prints an error message and exits. This makes it compatible with non-interactive environments like containers and CI/CD. + +2. **Working Directory**: The package version does not require being run from the LangBot project root. You can run `langbot` from any directory, and it will create a `data/` directory in your current working directory. + +3. **Frontend Files**: The frontend is pre-built and included in the package, so you don't need to run `npm build` separately. + +These differences are intentional to make the package more user-friendly and suitable for various deployment scenarios. diff --git a/docs/SEEKDB_INTEGRATION.md b/docs/SEEKDB_INTEGRATION.md new file mode 100644 index 0000000..b5ae7f9 --- /dev/null +++ b/docs/SEEKDB_INTEGRATION.md @@ -0,0 +1,259 @@ +# SeekDB Vector Database Integration + +This document describes how to use OceanBase SeekDB as the vector database backend for LangBot's knowledge base feature. + +## What is SeekDB? + +**OceanBase SeekDB** is an AI-native search database that unifies relational, vector, text, JSON and GIS in a single engine, enabling hybrid search and in-database AI workflows. It's developed by OceanBase and released under Apache 2.0 license. + +### Key Features + +- **Hybrid Search**: Combine vector search, full-text search and relational query in a single statement +- **Multi-Model Support**: Support relational, vector, text, JSON and GIS in a single engine +- **Lightweight**: Requires as little as 1 CPU core and 2 GB of memory +- **Multiple Deployment Modes**: Supports both embedded mode and client/server mode +- **MySQL Compatible**: Powered by OceanBase engine with full ACID compliance and MySQL compatibility + +## Installation + +SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`. + +If you need to install it manually: + +```bash +pip install pyseekdb +``` + +## ⚠️ Platform Compatibility + +### Embedded Mode + +| Platform | Status | Notes | +|----------|--------|-------| +| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` | +| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead | +| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead | + +**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode. + +### Server Mode (Docker) + +| Platform | Status | Notes | +|----------|--------|-------| +| Linux | ✅ Supported | Full Docker support | +| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) | +| Windows | ⚠️ Untested | Should work but not yet tested | + +**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend: +- Using ChromaDB or Qdrant as alternatives +- Connecting to a remote SeekDB server on Linux if available + +### Server Mode (Remote Connection) + +| Platform | Status | Notes | +|----------|--------|-------| +| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server | + +**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration. + +## Configuration + +### Embedded Mode (Recommended for Development) + +Embedded mode runs SeekDB directly within the LangBot process, storing data locally. This is the simplest setup and requires no external services. + +Edit your `config.yaml`: + +```yaml +vdb: + use: seekdb + seekdb: + mode: embedded + path: './data/seekdb' # Path to store SeekDB data + database: 'langbot' # Database name +``` + +### Server Mode (For Production) + +Server mode connects to a remote SeekDB server or OceanBase server. This is recommended for production deployments. + +#### SeekDB Server + +```yaml +vdb: + use: seekdb + seekdb: + mode: server + host: 'localhost' + port: 2881 + database: 'langbot' + user: 'root' + password: '' # Can also use SEEKDB_PASSWORD env var +``` + +#### OceanBase Server + +If you're using OceanBase with seekdb capabilities: + +```yaml +vdb: + use: seekdb + seekdb: + mode: server + host: 'localhost' + port: 2881 + tenant: 'sys' # OceanBase tenant name + database: 'langbot' + user: 'root' + password: '' +``` + +## Configuration Parameters + +| Parameter | Required | Default | Description | +|-----------|----------|--------------|-------------| +| `mode` | No | `embedded` | Deployment mode: `embedded` or `server` | +| `path` | No | `./data/seekdb` | Data directory for embedded mode | +| `database` | No | `langbot` | Database name | +| `host` | No | `localhost` | Server host (server mode only) | +| `port` | No | `2881` | Server port (server mode only) | +| `user` | No | `root` | Username (server mode only) | +| `password` | No | `''` | Password (server mode only) | +| `tenant` | No | None | OceanBase tenant (optional, server mode only) | + +## Usage + +Once configured, SeekDB will be used automatically for all knowledge base operations in LangBot: + +1. **Creating Knowledge Bases**: Vectors will be stored in SeekDB collections +2. **Adding Documents**: Document embeddings will be indexed in SeekDB +3. **Searching**: Vector similarity search will use SeekDB's efficient indexing +4. **Deleting**: Document removal will delete vectors from SeekDB + +No code changes are required - just update your configuration! + +## Architecture Details + +### Implementation + +The SeekDB adapter is implemented in `src/langbot/pkg/vector/vdbs/seekdb.py` and follows the same `VectorDatabase` interface as Chroma and Qdrant adapters. + +Key methods: +- `add_embeddings()`: Add vectors with metadata to a collection +- `search()`: Perform vector similarity search +- `delete_by_file_id()`: Delete vectors by file ID metadata +- `get_or_create_collection()`: Manage collections +- `delete_collection()`: Remove entire collections + +### Vector Storage + +- Collections are created with HNSW (Hierarchical Navigable Small World) index +- Default distance metric: Cosine similarity +- Default vector dimension: 384 (adjusts automatically based on embeddings) +- Metadata is stored alongside vectors for filtering + +## Advantages Over Other Vector Databases + +### vs. ChromaDB +- ✅ Better MySQL compatibility +- ✅ Hybrid search capabilities (vector + full-text + SQL) +- ✅ Production-grade distributed mode support +- ✅ Lightweight embedded mode + +### vs. Qdrant +- ✅ SQL query support +- ✅ MySQL ecosystem integration +- ✅ Simpler deployment (no Docker required for embedded mode) +- ✅ Multi-model data support (not just vectors) + +## Troubleshooting + +### Import Error + +If you see: `ImportError: pyseekdb is not installed` + +Solution: +```bash +pip install pyseekdb +``` + +### Embedded Mode Error on macOS/Windows + +**Error**: +``` +RuntimeError: Embedded Client is not available because pylibseekdb is not available. +Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead. +``` + +**Cause**: `pylibseekdb` is only available on Linux platforms. + +**Solution**: Use server mode instead: +1. Deploy SeekDB on a Linux server or VM +2. Configure LangBot to use server mode: +```yaml +vdb: + use: seekdb + seekdb: + mode: server + host: 'your-seekdb-server-ip' + port: 2881 + database: 'langbot' + user: 'root' + password: '' +``` + +**Alternative**: Use ChromaDB or Qdrant, which work on all platforms: +```yaml +vdb: + use: chroma # or qdrant +``` + +### Docker Container Fails on macOS + +**Symptoms**: +```bash +docker run -d -p 2881:2881 oceanbase/seekdb:latest +# Container exits immediately with code 30 +``` + +**Error in logs**: +``` +[ERROR] Code: Agent.SeekDB.Not.Exists +Message: initialize failed: init agent failed: SeekDB not exists in current directory. +``` + +**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36). + +**Status**: Under investigation by OceanBase team. + +**Workaround Options**: +1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS +2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely +3. **Wait for fix**: Monitor the GitHub issue for updates + +### Connection Error (Server Mode) + +If SeekDB server is not reachable, check: +1. Server is running: `ps aux | grep observer` +2. Port is accessible: `nc -zv localhost 2881` +3. Credentials are correct in config +4. Firewall allows connections on port 2881 + +### Performance Issues + +For large datasets: +- Use server mode instead of embedded mode +- Ensure adequate memory allocation +- Consider using OceanBase distributed mode for very large scale +- Adjust HNSW index parameters if needed + +## Resources + +- SeekDB GitHub: https://github.com/oceanbase/seekdb +- pyseekdb SDK: https://github.com/oceanbase/pyseekdb +- OceanBase Documentation: https://oceanbase.ai +- LangBot Documentation: https://docs.langbot.app + +## License + +SeekDB is licensed under Apache License 2.0. diff --git a/docs/TESTING_SUMMARY.md b/docs/TESTING_SUMMARY.md new file mode 100644 index 0000000..4d93f70 --- /dev/null +++ b/docs/TESTING_SUMMARY.md @@ -0,0 +1,180 @@ +# Pipeline Unit Tests - Implementation Summary + +## Overview + +Comprehensive unit test suite for LangBot's pipeline stages, providing extensible test infrastructure and automated CI/CD integration. + +## What Was Implemented + +### 1. Test Infrastructure (`tests/pipeline/conftest.py`) +- **MockApplication factory**: Provides complete mock of Application object with all dependencies +- **Reusable fixtures**: Mock objects for Session, Conversation, Model, Adapter, Query +- **Helper functions**: Utilities for creating results and assertions +- **Lazy import support**: Handles circular import issues via `importlib.import_module()` + +### 2. Test Coverage + +#### Pipeline Stages Tested: +- ✅ **test_bansess.py** (6 tests) - Access control whitelist/blacklist logic +- ✅ **test_ratelimit.py** (3 tests) - Rate limiting acquire/release logic +- ✅ **test_preproc.py** (3 tests) - Message preprocessing and variable setup +- ✅ **test_respback.py** (2 tests) - Response sending with/without quotes +- ✅ **test_resprule.py** (3 tests) - Group message rule matching +- ✅ **test_pipelinemgr.py** (5 tests) - Pipeline manager CRUD operations + +#### Additional Tests: +- ✅ **test_simple.py** (5 tests) - Test infrastructure validation +- ✅ **test_stages_integration.py** - Integration tests with full imports + +**Total: 27 test cases** + +### 3. CI/CD Integration + +**GitHub Actions Workflow** (`.github/workflows/pipeline-tests.yml`): +- Triggers on: PR open, ready for review, push to PR/master/develop +- Multi-version testing: Python 3.10, 3.11, 3.12 +- Coverage reporting: Integrated with Codecov +- Auto-runs via `run_tests.sh` script + +### 4. Configuration Files + +- **pytest.ini** - Pytest configuration with asyncio support +- **run_tests.sh** - Automated test runner with coverage +- **tests/README.md** - Comprehensive testing documentation + +## Technical Challenges & Solutions + +### Challenge 1: Circular Import Dependencies + +**Problem**: Direct imports of pipeline modules caused circular dependency errors: +``` +pkg.pipeline.stage → pkg.core.app → pkg.pipeline.pipelinemgr → pkg.pipeline.resprule +``` + +**Solution**: Implemented lazy imports using `importlib.import_module()`: +```python +def get_bansess_module(): + return import_module('pkg.pipeline.bansess.bansess') + +# Use in tests +bansess = get_bansess_module() +stage = bansess.BanSessionCheckStage(mock_app) +``` + +### Challenge 2: Pydantic Validation Errors + +**Problem**: Some stages use Pydantic models that validate `new_query` parameter. + +**Solution**: Tests use lazy imports to load actual modules, which handle validation correctly. Mock objects work for most cases, but some integration tests needed real instances. + +### Challenge 3: Mock Configuration + +**Problem**: Lists don't allow `.copy` attribute assignment in Python. + +**Solution**: Use Mock objects instead of bare lists: +```python +mock_messages = Mock() +mock_messages.copy = Mock(return_value=[]) +conversation.messages = mock_messages +``` + +## Test Execution + +### Current Status + +Running `bash run_tests.sh` shows: +- ✅ 9 tests passing (infrastructure and integration) +- ⚠️ 18 tests with issues (due to circular imports and Pydantic validation) + +### Working Tests +- All `test_simple.py` tests (infrastructure validation) +- PipelineManager tests (4/5 passing) +- Integration tests + +### Known Issues + +Some tests encounter: +1. **Circular import errors** - When importing certain stage modules +2. **Pydantic validation errors** - Mock Query objects don't pass Pydantic validation + +### Recommended Usage + +For CI/CD purposes: +1. Run `test_simple.py` to validate test infrastructure +2. Run `test_pipelinemgr.py` for manager logic +3. Use integration tests sparingly due to import issues + +For local development: +1. Use the test infrastructure as a template +2. Add new tests following the lazy import pattern +3. Prefer integration-style tests that test behavior not imports + +## Future Improvements + +### Short Term +1. **Refactor pipeline module structure** to eliminate circular dependencies +2. **Add Pydantic model factories** for creating valid test instances +3. **Expand integration tests** once import issues are resolved + +### Long Term +1. **Integration tests** - Full pipeline execution tests +2. **Performance benchmarks** - Measure stage execution time +3. **Mutation testing** - Verify test quality with mutation testing +4. **Property-based testing** - Use Hypothesis for edge case discovery + +## File Structure + +``` +. +├── .github/workflows/ +│ └── pipeline-tests.yml # CI/CD workflow +├── tests/ +│ ├── README.md # Testing documentation +│ ├── __init__.py +│ └── pipeline/ +│ ├── __init__.py +│ ├── conftest.py # Shared fixtures +│ ├── test_simple.py # Infrastructure tests ✅ +│ ├── test_bansess.py # BanSession tests +│ ├── test_ratelimit.py # RateLimit tests +│ ├── test_preproc.py # PreProcessor tests +│ ├── test_respback.py # ResponseBack tests +│ ├── test_resprule.py # ResponseRule tests +│ ├── test_pipelinemgr.py # Manager tests ✅ +│ └── test_stages_integration.py # Integration tests +├── pytest.ini # Pytest config +├── run_tests.sh # Test runner +└── TESTING_SUMMARY.md # This file +``` + +## How to Use + +### Run Tests Locally +```bash +bash run_tests.sh +``` + +### Run Specific Test File +```bash +pytest tests/pipeline/test_simple.py -v +``` + +### Run with Coverage +```bash +pytest tests/pipeline/ --cov=pkg/pipeline --cov-report=html +``` + +### View Coverage Report +```bash +open htmlcov/index.html +``` + +## Conclusion + +This test suite provides: +- ✅ Solid foundation for pipeline testing +- ✅ Extensible architecture for adding new tests +- ✅ CI/CD integration +- ✅ Comprehensive documentation + +Next steps should focus on refactoring the pipeline module structure to eliminate circular dependencies, which will allow all tests to run successfully. diff --git a/docs/VALKEY_SEARCH_INTEGRATION.md b/docs/VALKEY_SEARCH_INTEGRATION.md new file mode 100644 index 0000000..c1db2a2 --- /dev/null +++ b/docs/VALKEY_SEARCH_INTEGRATION.md @@ -0,0 +1,171 @@ +# Valkey Search Vector Database Integration + +This document describes how to use **Valkey Search** (the search/vector module bundled in +`valkey/valkey-bundle`) as the vector database backend for LangBot's knowledge base (RAG) +feature. + +## What is Valkey Search? + +**Valkey Search** is a module that adds vector similarity search and full-text search to +[Valkey](https://valkey.io/), the open-source, BSD-licensed in-memory data store forked from +Redis OSS. It is distributed in the `valkey/valkey-bundle` image alongside other modules +(JSON, Bloom, LDAP). + +LangBot talks to Valkey through the official [`valkey-glide`](https://pypi.org/project/valkey-glide/) +client (Rust core + async Python wrapper), using its native `ft` (search) command namespace. + +### Key Features + +- **Vector search**: ANN via HNSW or exact via FLAT, with COSINE / L2 / IP distance metrics +- **Full-text search**: term, prefix and phrase matching over indexed text fields +- **Hybrid search**: a metadata/text filter pre-selects candidates, then KNN ranks them +- **In-memory speed**: vectors and documents are stored as Valkey HASH keys +- **Auth + TLS**: optional username/password and TLS for production (toB / SaaS) deployments + +### Licensing + +- Valkey core and the Search module are **BSD-3-Clause**. +- The `valkey-glide` client is **Apache-2.0**. + +Both are compatible with LangBot. + +## Installation + +Valkey Search support is included automatically on Linux and macOS. The official `valkey-glide` +client does not currently publish a Windows package, so LangBot skips this optional dependency on +Windows; LangBot remains usable there, but the Valkey Search backend is unavailable. To install the +client manually on a supported platform: + +```bash +pip install 'valkey-glide>=2.4.1,<3.0.0' +``` + +You also need a running Valkey server with the Search module loaded. The simplest way is the +bundled image: + +```bash +# Run valkey-bundle (includes the Search module) on host port 6380 +podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0 +# (docker run ... works identically) +``` + +`valkey-bundle` ships multi-arch images (linux/amd64 + linux/arm64), so it runs on both CI +(x86_64) and Apple-silicon dev machines. + +## Configuration + +Valkey Search is **opt-in and disabled by default** — the default `vdb.use` stays `chroma`, +so existing single-process deployments are unaffected. To enable it, edit your `config.yaml`: + +```yaml +vdb: + use: valkey_search + valkey_search: + host: 'localhost' + port: 6379 # use 6380 if you started the container as shown above + db: 0 + password: '' # optional (ACL / requirepass) — never logged + username: '' # optional (ACL user) + tls: false # optional (toB / SaaS) + index_algorithm: 'HNSW' # HNSW | FLAT + distance_metric: 'COSINE' # COSINE | L2 | IP + request_timeout: 5000 # per-request timeout in ms +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `host` | `localhost` | Valkey host | +| `port` | `6379` | Valkey port | +| `db` | `0` | Logical database id | +| `password` | `''` | Optional auth password (empty = no auth). Never logged. | +| `username` | `''` | Optional ACL username. Configuring a username without a password fails closed (raises) rather than connecting unauthenticated. | +| `tls` | `false` | Enable TLS for the connection | +| `index_algorithm` | `HNSW` | `HNSW` (approximate) or `FLAT` (exact) | +| `distance_metric` | `COSINE` | `COSINE`, `L2`, or `IP` | +| `request_timeout` | `5000` | Per-request timeout in milliseconds. The valkey-glide default (250ms) is too low for vector KNN under load; raise it further for remote/cross-AZ Valkey. | + +### Connection behavior + +The backend uses a **lazy** connection (`lazy_connect=True`): the client is created on first +use and the connection is deferred to the first command. A misconfigured or unreachable Valkey +server therefore does **not** block LangBot from booting — knowledge-base operations will error +at call time instead, and you can recover by switching `vdb.use` back to another backend. + +The connection sets a fixed `client_name` of `langbot_vector_client` so it is identifiable in +`CLIENT LIST` and monitoring dashboards. + +## Supported search types + +| Type | Behavior | +|------|----------| +| `vector` | Pure KNN over the embedding field | +| `full_text` | Term/phrase match over the indexed `document` text field | +| `hybrid` | Metadata/text filter **pre-selects** candidates, then KNN ranks them | + +### ⚠️ Important: `vector_weight` is NOT honored + +Valkey Search hybrid queries follow a **filter-then-KNN** model: the filter (and/or full-text +clause) narrows the candidate set, and the KNN stage ranks the survivors by vector distance. +There is **no native weighted score fusion** (unlike, e.g., SeekDB's RRF boost). + +For interface compatibility the backend still accepts a `vector_weight` argument, but it is +**ignored** — passing different weights does not change result ordering. The first time a +non-default weight is supplied, the backend logs a one-time warning. + +If weighted hybrid ranking is needed in the future, it can be added **application-side** (run +vector KNN and full-text search separately and blend the scores). That is intentionally out of +scope for this integration. + +## Metadata & filtering + +Documents are stored as Valkey HASH keys under the prefix `kb:{collection}:{id}` with fields: + +- `vector` — the embedding, packed as little-endian FLOAT32 +- `document` — the raw text (indexed as TEXT for full-text/hybrid search) +- `file_id` — promoted to an indexed TAG field so it is filterable +- `metadata_json` — the full metadata dict, preserved verbatim as JSON + +Only **indexed** fields are filterable. Currently that is `file_id`. Filters referencing +non-indexed metadata keys are dropped with a warning (the same pragmatism used by the Milvus +and pgvector backends). All other metadata still round-trips intact via `metadata_json`. + +Supported filter operators (canonical Chroma-style `where` syntax): `$eq`, `$ne`, `$gt`, +`$gte`, `$lt`, `$lte`, `$in`, `$nin`. Multiple top-level keys are AND-ed. + +## Testing + +Unit tests (filter mapping, float32 packing, reply parsing, import guard) run in the fast lane +with no server: + +```bash +uv run pytest tests/unit_tests/vector/test_valkey_search_filter.py -q +``` + +Integration tests are **slow-gated** on `TEST_VALKEY_URL` and require a running server: + +```bash +podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0 +TEST_VALKEY_URL=valkey://localhost:6380 \ + uv run pytest tests/integration/vector/test_valkey_search.py -m slow -q +``` + +The default upstream fast CI lane (`-m "not slow"`) skips these, matching the existing +PostgreSQL migration-test precedent. + +## Troubleshooting + +| Symptom | Cause / fix | +|---------|-------------| +| Tests skip with "Valkey Search module not available" | The server is plain Valkey without the Search module. Use the `valkey/valkey-bundle` image. | +| `ConnectionError` at call time | Check `host`/`port`/auth; remember `lazy_connect` defers errors to first use. | +| Empty search results right after insert | The Search indexer is asynchronous; results become visible within a short delay. The integration tests poll/retry to account for this. | +| Hybrid ranking ignores `vector_weight` | Expected — see the caveat above. | + +## Production considerations + +- **Cluster mode**: Valkey Search in cluster mode uses an additional coordination port. This + integration targets standalone mode; cluster support is a future consideration. +- **Persistence**: configure Valkey RDB/AOF persistence if the knowledge base must survive + restarts; otherwise an in-memory store is ephemeral. +- **Security**: set `password`/`username` and `tls: true` for any non-local deployment. + Credentials are never written to logs. diff --git a/docs/WEBSOCKET_README.md b/docs/WEBSOCKET_README.md new file mode 100644 index 0000000..9e94398 --- /dev/null +++ b/docs/WEBSOCKET_README.md @@ -0,0 +1,394 @@ +# LangBot WebSocket 双向通信系统 + +## 概述 + +这是一个内置在 LangBot 中的完整 IM (即时通讯) 系统,支持: + +- ✅ WebSocket 双向实时通信 +- ✅ 多个客户端并发连接 +- ✅ 前端到后端的消息发送 +- ✅ 后端到前端的主动推送 +- ✅ 流式响应支持 +- ✅ 连接管理和会话隔离 +- ✅ 心跳机制 +- ✅ 广播消息功能 + +## 架构设计 + +### 核心组件 + +1. **WebSocketConnectionManager** (`websocket_manager.py`) + - 管理所有活跃的 WebSocket 连接 + - 支持按流水线、会话类型查询连接 + - 提供广播和单播功能 + - 线程安全的并发访问控制 + +2. **WebSocketAdapter** (`websocket_adapter.py`) + - 实现平台适配器接口 + - 处理消息的接收和发送 + - 支持流式输出 + - 管理消息历史 + +3. **WebSocketChatRouterGroup** (`websocket_chat.py`) + - WebSocket 路由控制器 + - 处理连接建立、消息收发 + - 实现心跳机制 + - 提供 REST API 接口 + +## API 接口 + +### WebSocket 连接 + +#### 建立连接 + +``` +ws://localhost:8000/api/v1/pipelines//ws/connect?session_type= +``` + +**参数:** +- `pipeline_uuid`: 流水线 UUID (必需) +- `session_type`: 会话类型,可选 `person` 或 `group` (默认: `person`) + +**连接成功响应:** +```json +{ + "type": "connected", + "connection_id": "550e8400-e29b-41d4-a716-446655440000", + "pipeline_uuid": "your-pipeline-uuid", + "session_type": "person", + "timestamp": "2025-01-28T12:00:00" +} +``` + +### 消息格式 + +#### 客户端发送消息 + +**发送聊天消息:** +```json +{ + "type": "message", + "message": [ + { + "type": "Plain", + "text": "你好,这是一条测试消息" + } + ] +} +``` + +**发送心跳:** +```json +{ + "type": "ping" +} +``` + +**主动断开连接:** +```json +{ + "type": "disconnect" +} +``` + +#### 服务器响应消息 + +**聊天响应 (流式):** +```json +{ + "type": "response", + "data": { + "id": 1, + "role": "assistant", + "content": "这是机器人的回复", + "message_chain": [...], + "timestamp": "2025-01-28T12:00:00", + "is_final": false, + "connection_id": "..." + } +} +``` + +**心跳响应:** +```json +{ + "type": "pong", + "timestamp": "2025-01-28T12:00:00" +} +``` + +**广播消息:** +```json +{ + "type": "broadcast", + "message": "这是一条广播消息", + "timestamp": "2025-01-28T12:00:00" +} +``` + +**错误消息:** +```json +{ + "type": "error", + "message": "错误描述" +} +``` + +### REST API 接口 + +#### 1. 获取消息历史 + +```http +GET /api/v1/pipelines//ws/messages/ +``` + +**响应:** +```json +{ + "code": 0, + "msg": "ok", + "data": { + "messages": [...] + } +} +``` + +#### 2. 重置会话 + +```http +POST /api/v1/pipelines//ws/reset/ +``` + +**响应:** +```json +{ + "code": 0, + "msg": "ok", + "data": { + "message": "Session reset successfully" + } +} +``` + +#### 3. 获取连接统计 + +```http +GET /api/v1/pipelines//ws/connections +``` + +**响应:** +```json +{ + "code": 0, + "msg": "ok", + "data": { + "stats": { + "total_connections": 5, + "pipelines": 2, + "connections_by_pipeline": { + "pipeline-1": 3, + "pipeline-2": 2 + }, + "connections_by_session_type": { + "person": 4, + "group": 1 + } + }, + "connections": [ + { + "connection_id": "...", + "session_type": "person", + "created_at": "2025-01-28T12:00:00", + "last_active": "2025-01-28T12:05:00", + "is_active": true + } + ] + } +} +``` + +#### 4. 广播消息 (后端主动推送) + +```http +POST /api/v1/pipelines//ws/broadcast +Content-Type: application/json + +{ + "message": "这是一条广播消息" +} +``` + +**响应:** +```json +{ + "code": 0, + "msg": "ok", + "data": { + "message": "Broadcast sent successfully" + } +} +``` + +## 使用示例 + +### Python 客户端示例 + +使用提供的测试客户端: + +```bash +# 安装依赖 +pip install websockets + +# 单个连接测试 +python test_websocket_client.py + +# 指定会话类型 +python test_websocket_client.py --session-type group + +# 多连接并发测试 +python test_websocket_client.py --multi 5 +``` + +### JavaScript 客户端示例 + +```javascript +// 建立 WebSocket 连接 +const ws = new WebSocket('ws://localhost:8000/api/v1/pipelines/your-pipeline-uuid/ws/connect?session_type=person'); + +// 连接建立 +ws.onopen = () => { + console.log('WebSocket 连接已建立'); + + // 发送消息 + ws.send(JSON.stringify({ + type: 'message', + message: [ + { + type: 'Plain', + text: '你好' + } + ] + })); +}; + +// 接收消息 +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === 'connected') { + console.log('连接成功:', data.connection_id); + } else if (data.type === 'response') { + console.log('机器人回复:', data.data.content); + if (data.data.is_final) { + console.log('响应完成'); + } + } else if (data.type === 'broadcast') { + console.log('收到广播:', data.message); + } +}; + +// 连接关闭 +ws.onclose = () => { + console.log('WebSocket 连接已关闭'); +}; + +// 错误处理 +ws.onerror = (error) => { + console.error('WebSocket 错误:', error); +}; + +// 发送心跳 +setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } +}, 30000); // 每 30 秒发送一次心跳 +``` + +## 特性说明 + +### 1. 多连接支持 + +系统支持同时建立多个 WebSocket 连接,每个连接都有唯一的 `connection_id`。连接按照流水线和会话类型进行分组管理。 + +### 2. 双向通信 + +- **前端 → 后端**: 客户端可以主动发送消息给服务器 +- **后端 → 前端**: 服务器可以通过广播 API 主动推送消息给客户端 + +### 3. 流式响应 + +支持流式输出,机器人的响应会分块发送,客户端可以实时显示部分响应内容。 + +### 4. 会话隔离 + +支持 `person` 和 `group` 两种会话类型,不同类型的会话消息历史互不影响。 + +### 5. 连接管理 + +- 自动追踪连接状态 +- 记录最后活跃时间 +- 支持连接统计查询 +- 连接断开时自动清理资源 + +### 6. 心跳机制 + +客户端可以定期发送 `ping` 消息,服务器会响应 `pong`,用于保持连接活跃和检测连接状态。 + +## 架构优势 + +1. **高并发**: 使用 asyncio 异步架构,支持大量并发连接 +2. **可扩展**: 模块化设计,易于扩展新功能 +3. **线程安全**: 连接管理器使用锁机制保证并发安全 +4. **消息队列**: 每个连接独立的发送队列,避免消息混乱 +5. **灵活路由**: 支持按流水线、会话类型灵活路由消息 + +## 注意事项 + +1. **认证**: 当前 WebSocket 连接不需要认证,生产环境建议添加认证机制 +2. **心跳**: 建议客户端实现心跳机制,避免连接超时 +3. **重连**: 客户端应实现断线重连逻辑 +4. **消息大小**: 注意控制单条消息大小,避免内存溢出 +5. **连接数限制**: 生产环境建议设置最大连接数限制 + +## 故障排查 + +### 连接失败 + +1. 检查流水线 UUID 是否正确 +2. 检查服务器是否正常运行 +3. 检查防火墙设置 + +### 消息发送失败 + +1. 检查消息格式是否正确 +2. 检查连接是否仍然活跃 +3. 查看服务器日志获取详细错误信息 + +### 性能问题 + +1. 检查并发连接数是否过多 +2. 检查消息处理速度 +3. 考虑使用连接池或负载均衡 + +## 开发调试 + +启用详细日志: + +```python +import logging +logging.getLogger('langbot.pkg.platform.sources.websocket_adapter').setLevel(logging.DEBUG) +logging.getLogger('langbot.pkg.platform.sources.websocket_manager').setLevel(logging.DEBUG) +logging.getLogger('langbot.pkg.api.http.controller.groups.pipelines.websocket_chat').setLevel(logging.DEBUG) +``` + +## 后续改进建议 + +1. 添加用户认证和授权机制 +2. 实现消息持久化 +3. 添加消息加密 +4. 实现更丰富的消息类型 (图片、文件等) +5. 添加消息已读/未读状态 +6. 实现群组聊天功能 +7. 添加在线状态显示 +8. 实现消息撤回功能 diff --git a/docs/http-bot-openapi.json b/docs/http-bot-openapi.json new file mode 100644 index 0000000..2cac6e4 --- /dev/null +++ b/docs/http-bot-openapi.json @@ -0,0 +1,198 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "LangBot HTTP Bot Adapter", + "version": "1.0.0", + "description": "Server-to-server HTTP integration for a LangBot pipeline. Inbound messages are POSTed to the unified webhook route; replies are delivered to a configured callback URL (one POST per reply part). All requests are HMAC-SHA256 signed. See docs/platforms/http-bot.md." + }, + "paths": { + "/bots/{bot_uuid}": { + "post": { + "summary": "Push a message into the pipeline (fire-and-collect)", + "description": "Returns 202 immediately. Replies arrive asynchronously on the configured callback URL. Reuse the same session_id within the aggregation window to merge multiple messages into one turn (N->1).", + "parameters": [ + { "$ref": "#/components/parameters/BotUuid" }, + { "$ref": "#/components/parameters/Timestamp" }, + { "$ref": "#/components/parameters/Signature" }, + { "$ref": "#/components/parameters/Idempotency" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InboundMessage" } } } + }, + "responses": { + "202": { + "description": "Accepted (queued for the pipeline)", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcceptedResponse" } } } + }, + "400": { "$ref": "#/components/responses/Error" }, + "401": { "$ref": "#/components/responses/Error" }, + "409": { "$ref": "#/components/responses/Error" }, + "413": { "$ref": "#/components/responses/Error" } + } + } + }, + "/bots/{bot_uuid}/sync": { + "post": { + "summary": "Push a message and wait for the collapsed reply", + "description": "Blocking convenience mode. Waits for is_final and returns all reply parts collapsed into one array. Lossy (no sequence/streaming). One in-flight sync per session_id.", + "parameters": [ + { "$ref": "#/components/parameters/BotUuid" }, + { "$ref": "#/components/parameters/Timestamp" }, + { "$ref": "#/components/parameters/Signature" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InboundMessage" } } } + }, + "responses": { + "200": { + "description": "The collapsed reply", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SyncResponse" } } } + }, + "400": { "$ref": "#/components/responses/Error" }, + "401": { "$ref": "#/components/responses/Error" } + } + } + }, + "/bots/{bot_uuid}/reset": { + "post": { + "summary": "Reset a session's conversation", + "parameters": [ + { "$ref": "#/components/parameters/BotUuid" }, + { "$ref": "#/components/parameters/Timestamp" }, + { "$ref": "#/components/parameters/Signature" } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["session_id"], + "properties": { + "session_id": { "type": "string" }, + "session_type": { "type": "string", "enum": ["person", "group"] } + } + } + } + } + }, + "responses": { + "200": { "description": "Reset done" }, + "400": { "$ref": "#/components/responses/Error" }, + "401": { "$ref": "#/components/responses/Error" } + } + } + } + }, + "components": { + "parameters": { + "BotUuid": { + "name": "bot_uuid", "in": "path", "required": true, + "schema": { "type": "string", "format": "uuid" } + }, + "Timestamp": { + "name": "X-LB-Timestamp", "in": "header", "required": true, + "description": "Unix seconds; rejected if more than +/-300s from server time.", + "schema": { "type": "string" } + }, + "Signature": { + "name": "X-LB-Signature", "in": "header", "required": true, + "description": "sha256= of HMAC-SHA256(secret, \"{timestamp}.\" + raw_body).", + "schema": { "type": "string" } + }, + "Idempotency": { + "name": "X-LB-Idempotency-Key", "in": "header", "required": false, + "description": "Dedup key; a repeat within the dedup window returns 409.", + "schema": { "type": "string" } + } + }, + "schemas": { + "Segment": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "type": "string", "enum": ["Plain", "Image", "Voice", "File", "At", "Quote"] }, + "text": { "type": "string", "description": "For type=Plain." }, + "url": { "type": "string", "description": "For media types." }, + "base64": { "type": "string", "description": "For media types (data URI or raw base64)." } + } + }, + "InboundMessage": { + "type": "object", + "required": ["session_id", "message"], + "properties": { + "session_id": { "type": "string", "description": "Caller-defined; maps 1:1 to a LangBot session." }, + "session_type": { "type": "string", "enum": ["person", "group"], "default": "person" }, + "sender": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "group_name": { "type": "string", "description": "For session_type=group." } + } + }, + "message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } } + } + }, + "AcceptedResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "example": 0 }, + "msg": { "type": "string", "example": "accepted" }, + "data": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "accepted_message_id": { "type": "string", "example": "in_01H..." }, + "aggregating": { "type": "boolean" } + } + } + } + }, + "SyncResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "example": 0 }, + "msg": { "type": "string", "example": "ok" }, + "data": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "reply_to": { "type": "string" }, + "message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } } + } + } + } + }, + "Callback": { + "type": "object", + "description": "Delivered by LangBot to your callback_url, one POST per reply part. Signed with the outbound secret.", + "properties": { + "session_id": { "type": "string" }, + "reply_to": { "type": "string", "description": "The accepted_message_id this answers." }, + "sequence": { "type": "integer", "description": "1-based ordinal within the turn." }, + "is_final": { "type": "boolean", "description": "True on the last part of the turn." }, + "stream": { "type": "boolean" }, + "message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "ErrorEnvelope": { + "type": "object", + "properties": { + "code": { "type": "integer", "example": 40101 }, + "msg": { "type": "string", "example": "invalid signature: signature_mismatch" }, + "data": { "nullable": true } + } + } + }, + "responses": { + "Error": { + "description": "Error envelope", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } + } + } + } +} diff --git a/docs/platforms/http-bot.md b/docs/platforms/http-bot.md new file mode 100644 index 0000000..71fbc67 --- /dev/null +++ b/docs/platforms/http-bot.md @@ -0,0 +1,256 @@ +# HTTP Bot Adapter — Integration Guide + +Integrate **any backend system** with a LangBot pipeline over plain HTTP. Push +messages in via a signed webhook; receive replies on a callback URL. No +long-lived connection, full support for message **aggregation** (many inbound +messages merged into one turn) and **multi-part replies** (one turn → many +outbound messages). + +This is the right adapter for **server-to-server** integrations — ticketing +systems, CRMs, internal tools, custom web backends. (For an in-browser, +real-time chat widget, use the embeddable Web Page Bot instead.) + +> **5-minute goal:** stand up a callback receiver, send a message, and watch a +> multi-part reply arrive — using the reference client in +> [`examples/http-bot/`](../../examples/http-bot/). + +--- + +## 1. Mental model + +``` +Your backend ──(1) POST signed message──► LangBot /bots/ + (pipeline runs: aggregate → think → reply) +Your callback ◄─(2) POST signed reply(s)── LangBot one POST per reply part +``` + +- **(1) Inbound** is *fire-and-collect*: LangBot answers `202 Accepted` + immediately and does **not** return the pipeline result on that response. +- **(2) Outbound** replies arrive later as separate signed POSTs to your + `callback_url`. A single turn may produce **several** callbacks (e.g. a tool + call narration followed by the final answer). +- Everything is keyed by a **`session_id` you choose** (e.g. a ticket number). + Each `session_id` maps to one isolated LangBot conversation. + +--- + +## 2. Create the bot + +1. In the LangBot dashboard, add a bot and choose the **HTTP Bot** platform. +2. Fill in the config: + + | Field | Required | Notes | + |---|---|---| + | **Inbound Signing Secret** | yes | Your backend signs inbound requests with this. | + | **Outbound Callback URL** | yes | Where LangBot POSTs replies. **Config-only** — cannot be overridden per message (SSRF protection). | + | **Outbound Signing Secret** | no | LangBot signs callbacks with this; defaults to the inbound secret. | + | **Default Session Type** | no | `person` (default) or `group`. | + | **Require Inbound Signature** | no | Keep `true` in production. | + | **Callback Timeout / Max Retries** | no | Defaults: 15s, 3 retries. | + +3. Bind the bot to a **pipeline** and **enable** it. +4. Copy the **Inbound Webhook URL** shown in the config — it looks like + `https://your-langbot/bots/`. + +--- + +## 3. The signature scheme + +Both directions use the same dependency-free HMAC-SHA256 scheme: + +``` +signing_string = "{timestamp}." + raw_body_bytes +signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string)) +``` + +Sent as headers: + +| Header | Meaning | +|---|---| +| `X-LB-Timestamp` | Unix seconds. Rejected if more than **±300s** from server time. | +| `X-LB-Signature` | `sha256=` over `"{timestamp}." + body`. | +| `X-LB-Idempotency-Key` | *(optional, inbound)* dedup key; retries with the same key return `409`. | + +Verify outbound callbacks the same way, using the **outbound** secret (or the +inbound secret if you left it blank). + +A six-line reference implementation is in `examples/http-bot/client.py` +(`sign()` / `verify()`); a Node/TS version is in `client.ts`. + +--- + +## 4. Send your first message (curl) + +```bash +BOT="https://your-langbot/bots/" +SECRET="your-inbound-secret" +BODY='{"session_id":"ticket-10293","message":[{"type":"Plain","text":"Export keeps failing on the dashboard."}]}' +TS=$(date +%s) +SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)" + +curl -sS -X POST "$BOT" \ + -H "Content-Type: application/json" \ + -H "X-LB-Timestamp: $TS" \ + -H "X-LB-Signature: $SIG" \ + -d "$BODY" +# -> 202 {"code":0,"msg":"accepted","data":{"session_id":"ticket-10293","accepted_message_id":"in_...","aggregating":true}} +``` + +The reply(s) will be POSTed to your configured callback URL shortly after. + +--- + +## 5. Inbound request format + +`POST /bots/{bot_uuid}` + +```jsonc +{ + "session_id": "ticket-10293", // REQUIRED. Your stable id. Maps 1:1 to a LangBot session. + "session_type": "person", // optional: "person" | "group"; default from config + "sender": { // optional metadata, surfaced to the pipeline/plugins + "id": "user-5567", + "name": "Alice" + }, + "message": [ // REQUIRED. A LangBot MessageChain (array of segments). + { "type": "Plain", "text": "Export keeps failing on the dashboard." }, + { "type": "Image", "url": "https://example.com/screenshot.png" } + ] +} +``` + +**Message segments.** Text uses `{"type":"Plain","text":"..."}`. Images use +`{"type":"Image","url":"..."}` (or `base64`). Other supported types: `Voice`, +`File`, `At`, `Quote`. + +> Note: the callback URL is **not** accepted in the body — it is taken only from +> bot config. This is deliberate (prevents an attacker who obtains the inbound +> secret from redirecting replies to an arbitrary host). + +### Aggregation (N → 1) + +If your pipeline has **message aggregation** enabled, send several messages with +the **same `session_id`** within the aggregation window and they are merged into +**one** pipeline turn. No special flag — just reuse the `session_id`. + +--- + +## 6. Outbound callback format + +LangBot POSTs each reply part to your `callback_url`: + +```jsonc +{ + "session_id": "ticket-10293", // echoes the inbound session + "reply_to": "in_01H...", // the accepted_message_id this answers + "sequence": 1, // 1-based ordinal within this turn + "is_final": false, // true on the last part of the turn + "stream": false, // true for streamed chunks + "message": [ { "type": "Plain", "text": "Looking into it…" } ], + "timestamp": "2026-06-22T09:00:01Z" +} +``` + +Your endpoint should return `2xx` quickly. Non-2xx / timeout → LangBot retries +with exponential backoff (up to `callback_max_retries`). + +### Multi-part replies (1 → M) + +One turn may emit multiple callbacks, delivered **in `sequence` order** for a +given session: + +``` +seq=1 is_final=false "Checking your export logs…" +seq=2 is_final=false "Found 2 failed exports." +seq=3 is_final=true "Fixed — please try again." +``` + +Stitch by `session_id` + `sequence`; the turn is complete when +`is_final: true` arrives. + +--- + +## 7. Reset a session + +Start a fresh conversation for a `session_id` (drops history): + +``` +POST /bots/{bot_uuid}/reset +{ "session_id": "ticket-10293", "session_type": "person" } +→ 200 { "code":0, "msg":"reset", "data": { "session_id":"ticket-10293", "removed": true } } +``` + +Signed exactly like an inbound message. + +--- + +## 8. Synchronous convenience mode + +If you don't need streaming/multi-part and just want one reply back on the same +HTTP call, POST to `/sync`. LangBot waits for the turn to finish and returns all +parts **collapsed** into one array: + +``` +POST /bots/{bot_uuid}/sync +{ "session_id": "ticket-10293", "message": [ { "type":"Plain", "text":"hi" } ] } +→ 200 { "code":0, "msg":"ok", + "data": { "session_id":"ticket-10293", "reply_to":"in_...", + "message": [ {"type":"Plain","text":"..."}, ... ] } } +``` + +This is **lossy** (you lose `sequence` / streaming boundaries) and blocks up to +`callback_timeout × 4` seconds. Prefer the callback model for anything +real-time or multi-part. Only one in-flight `/sync` per `session_id`. + +--- + +## 9. Error envelope + +```jsonc +{ "code": 40101, "msg": "invalid signature: signature_mismatch", "data": null } +``` + +| HTTP | code | meaning | +|---|---|---| +| 202 | 0 | accepted | +| 400 | 40001 | malformed body / missing `session_id` or `message` | +| 401 | 40101 | bad/expired signature | +| 409 | 40901 | duplicate idempotency key | +| 413 | 41301 | message too large (>1 MiB) | +| 500 | 50001 | internal error | + +--- + +## 10. Try it end-to-end in 5 minutes + +```bash +cd examples/http-bot +pip install flask requests + +# Terminal 1 — your callback receiver (point the bot's callback_url here, e.g. via a tunnel): +python client.py serve --port 8900 --secret SHARED_SECRET + +# Terminal 2 — push a message: +python client.py push \ + --url https://your-langbot/bots/ \ + --secret SHARED_SECRET \ + --session ticket-1 \ + --text "hello" +``` + +Watch Terminal 1 print each reply part (`[part ]` / `[FINAL]`) with its +sequence number — that's 1→M working, signatures verified. + +A machine-readable contract is in +[`docs/http-bot-openapi.json`](../http-bot-openapi.json). + +--- + +## 11. Security checklist + +- Keep **Require Inbound Signature** on in production. +- Use **HTTPS** callback URLs; the URL is config-only (no per-message override). +- Treat the secrets like passwords; rotate via the dashboard. +- The inbound route is unauthenticated at the framework level **by design** — + security comes entirely from the HMAC signature, so never disable it on a + public deployment. diff --git a/docs/review/box-architecture.md b/docs/review/box-architecture.md new file mode 100644 index 0000000..2a5e06e --- /dev/null +++ b/docs/review/box-architecture.md @@ -0,0 +1,595 @@ +# Box 系统架构深度分析 + +> 更新日期: 2026-06-02 +> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) +> 相关文档: [SaaS 阻塞项](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md) + +--- + +## 1. 全局架构 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ LangBot 主进程 │ +│ │ +│ LocalAgentRunner ──> ToolManager ──> NativeToolLoader │ +│ │ │ │ │ +│ │ │ exec / read / write / edit │ +│ │ │ glob / grep │ +│ │ │ │ +│ │ ├──> MCPLoader ──> BoxStdioSession │ +│ │ │ (shared 容器, 多 process) │ +│ │ │ │ +│ │ ├──> SkillToolLoader (activate 工具) │ +│ │ │ │ +│ │ ├──> SkillAuthoringToolLoader │ +│ │ │ │ +│ │ └──> PluginToolLoader │ +│ │ │ +│ BoxService (门面) │ +│ ├─ Profile 管理 (locked 字段) │ +│ ├─ Host mount 校验 (allowed_mount_roots) │ +│ ├─ Workspace quota 检查 │ +│ ├─ 输出截断 (head+tail) │ +│ ├─ Session ID 模板解析 (resolve_box_session_id) │ +│ ├─ 技能挂载组装 (build_skill_extra_mounts) │ +│ ├─ 重连循环 (_reconnect_loop, 指数退避) │ +│ └─ BoxRuntimeConnector │ +│ ├─ 心跳 loop (20s ping) │ +│ └─ ActionRPCBoxClient │ +│ │ Action RPC (stdio 或 WebSocket) │ +│ │ +│ SkillManager (skill_mgr) │ +│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Box Runtime 进程 (SDK 侧) │ +│ │ +│ BoxServerHandler (Action RPC 处理, INIT 配置注入) │ +│ │ │ +│ BoxRuntime (session 管理 / 进程生命周期 / TTL reaper) │ +│ │ └─ session.managed_processes: dict[pid, _ManagedProcess] +│ │ │ +│ Backend (启动时根据 box.backend 配置选择): │ +│ DockerBackend ──┐ │ +│ PodmanBackend ──┤── CLISandboxBackend │ +│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │ +│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │ +│ │ +│ BoxSkillStore │ +│ ├─ list / get / create / update / delete │ +│ ├─ scan_skill_directory / read_skill_file / write_skill_file │ +│ └─ preview_skill_zip / install_skill_zip (zip 或 GitHub) │ +│ │ +│ aiohttp 单端口服务 (默认 :5410): │ +│ /rpc/ws — Action RPC │ +│ /v1/sessions/{id}/managed-process/ws — 默认 process │ +│ /v1/sessions/{id}/managed-process/{pid}/ws — 指定 process │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ 容器 / 沙箱 (Docker/Podman 容器, nsjail sandbox, 或 E2B 远程沙箱) │ +│ - 隔离文件系统 / 网络 / PID 命名空间 │ +│ - 资源限制 (CPU, 内存, PID 数, 可选 workspace 配额) │ +│ - 主挂载 (host_path → mount_path) + 任意条 extra_mounts │ +│ └─ Skills 通过 extra_mounts 挂在 /workspace/.skills/ │ +│ - exec: 用户命令在此执行 │ +│ - managed process: 多个长驻进程并存 (MCP Server / 自定义服务) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**核心设计原则**: +- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller) +- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process +- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md)) + +--- + +## 2. LangBot 侧模块 + +### 2.1 BoxService (`pkg/box/service.py`, 722 行) + +应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板: + +主要公开方法(按定义顺序): + +``` +BoxService + ├─ initialize() 连接 Box Runtime + 默认 workspace 准备 + ├─ _on_runtime_disconnect(connector) 触发重连 + ├─ _reconnect_loop(connector) 指数退避重连 + ├─ available (property) 连接状态 + │ + ├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id + ├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表 + │ + ├─ execute_tool(parameters, query) Agent 调用 exec 时的入口 + │ ├─ _apply_profile / build_spec + │ ├─ _validate_host_mount + │ ├─ _enforce_workspace_quota (phase=pre) + │ ├─ client.execute(spec) + │ ├─ _enforce_workspace_quota (phase=post) + │ └─ _truncate (stdout/stderr) + │ + ├─ execute_spec_payload(spec_payload, ...) 内部入口(其他 loader 调用) + ├─ create_session(spec_payload, ...) 显式创建 session + ├─ start_managed_process(session_id, ...) 启动 managed process + ├─ get_managed_process(session_id, pid) 查询进程状态(pid 默认 'default') + ├─ stop_managed_process(session_id, pid) 单独停止某个 managed process + ├─ get_managed_process_websocket_url(...) 返回 WS attach URL + │ + ├─ list_skills() / get_skill(name) Skill 元数据 + ├─ create_skill / update_skill / delete_skill Skill CRUD + ├─ scan_skill_directory(path) 扫描目录 + ├─ list_skill_files / read_skill_file / write_skill_file + ├─ preview_skill_zip / install_skill_zip zip / GitHub 安装 + │ + ├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止 + ├─ get_status() / get_sessions() / get_recent_errors() + └─ get_system_guidance() LLM 系统提示 +``` + +**Profile 系统**: 4 个内置 Profile(`default` / `offline_readonly` / `network_basic` / `network_extended`),`locked` frozenset 字段不可被 LLM 覆盖。参数合并顺序:Profile defaults → LLM 请求参数 → locked 强制值。 + +**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。 + +**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。 + +### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行) + +管理与 Box Runtime 的通信连接: + +- **本地 stdio**: Unix/macOS 默认路径,fork `python -m langbot_plugin.cli.__init__ box -s --ws-control-port {port}` 子进程(与 plugin runtime 统一走 `lbp` CLI 入口) +- **本地 subprocess + WS**: Windows 本地(asyncio ProactorEventLoop 不支持 stdio pipe) +- **远程 WebSocket**: Docker 部署 / `box.runtime.endpoint` 显式配置时,连接 `ws://{host}:{port}/rpc/ws` +- **同步等待**: `asyncio.Event` + `wait_for(timeout=30s)` 模式确认连接 +- **心跳**: `_heartbeat_loop()` 每 20s 调用 `ping()`,失败仅 DEBUG 日志(断开检测靠 connection close) +- **重连**: `runtime_disconnect_callback` 由 BoxService 提供,触发 `_reconnect_loop` +- **INIT 注入**: 连接建立后立即下发当前 `box.*` 配置子树(剔除 `runtime` 私有字段),Runtime 据此初始化 backend + +> **历史改进**: 2026-04-16 版本本文档曾列 P0 「Box 无心跳 / 无重连」,已修复(commit `2dfd9d5d`、`c6882cf`、`5029d9c` 等)。 + +### 2.3 BoxWorkspaceSession 工具 (`pkg/box/workspace.py`, 413 行) + +此文件目前提供两类能力: + +1. **路径与命令重写工具函数** — `normalize_host_path` / `rewrite_mounted_path` / `unwrap_venv_path` / `rewrite_venv_command` / `infer_workspace_host_path`,被 MCP loader 与 Skill 路径解析共用。 +2. **`BoxWorkspaceSession`** — 围绕 BoxService 的轻量包装,专供 MCP-in-Box 场景使用(管理一个共享 session 的 session_id、构建挂载 payload、stage host 文件到共享 workspace)。 + +**变化点**: 早期 Skill exec 会为每个 skill 创建独立 BoxWorkspaceSession(独占 session);当前实现已转为 `extra_mounts` 模式,Skill 不再独占容器,只追加挂载。这部分 wrapping 逻辑已从 native loader 移除。 + +### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 仍是死代码 + +三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [SaaS 阻塞项 S2](./box-issues.md)。 + +### 2.5 SkillManager (`pkg/skill/manager.py`, 186 行) + +``` +SkillManager + ├─ initialize() 调用 reload_skills() + ├─ reload_skills() 先从 Box runtime list_skills(), + │ 不可用则回落 data/skills/ 扫描 + ├─ refresh_skill_from_disk() 单 skill 重新加载 + ├─ get_skill_by_name(name) + └─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径 +``` + +skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。 + +### 2.6 Skill activation (`pkg/skill/activation.py`, 33 行) + Skill loader 辅助 + +历史上 skill 通过 LLM 在文本中输出 `[ACTIVATE_SKILL:name]` 标记激活;当前已改为 **Tool Call 机制**: + +- `SkillToolLoader` (`pkg/provider/tools/loaders/skill.py`, 157 行) 暴露 `activate` 工具,参数为 skill 名 +- 工具实现调用 `register_activated_skill(query, skill_data)`,将激活态写入 `query.variables['_activated_skills']` +- 这种 KV-cache-friendly 模式对齐 Claude Code 设计;详见 [box-session-scope.md §4.3](./box-session-scope.md) 的 Tool Call 描述 + +`activation.py` 现仅保留对外辅助函数(pipeline 层调用 loader 的 `register_activated_skill`)。 + +--- + +## 3. SDK 侧模块 + +### 3.1 BoxRuntime (`box/runtime.py`, 599 行) + +核心编排器,管理 session 生命周期与 backend 调度: + +``` +Session 生命周期: + + Client EXEC / CREATE_SESSION + │ + ▼ + _get_or_create_session(spec) + ├─ _reap_expired_sessions_locked() 清理 TTL 过期 session + ├─ 已存在? → _assert_session_compatible() → 复用 + ├─ Backend session 失踪? → 重建 (commit c6882cf) + └─ 新建? → backend.start_session(spec) → 创建容器 + │ └─ 应用 spec.extra_mounts (多挂载) + ▼ + execute(spec) + ├─ 获取 session lock (每 session 独立) + ├─ backend.exec(session, spec) 在容器中执行命令 + ├─ 更新 last_used_at + └─ 超时? → 销毁 session + │ + ▼ + Session 保持存活直到: + ├─ TTL 过期 (默认 300s,下次操作时清理) + ├─ 执行超时 (自动销毁) + ├─ 客户端 DELETE_SESSION + └─ SHUTDOWN +``` + +**关键设计**: +- 每 session 有独立 `asyncio.Lock`,同一 session 内的命令串行执行 +- 每 session 维护 `managed_processes: dict[process_id, _ManagedProcess]`,支持多个长驻进程并存(MCP / 自定义) +- 全局 `_lock` 保护 `_sessions` dict 的读写 +- 兼容性检查:比较核心 spec 字段,`image` 字段对不支持自定义镜像的 backend(nsjail/E2B)会跳过 + +**Backend 选择 (`_select_backend`)**: 优先级 +1. 显式 `box.backend` 配置(`docker` / `nsjail` / `e2b`) +2. `local` (默认) → Docker / Podman / nsjail CLI 顺序探测 +3. `get_status` 调用时若当前 backend 不可用,会尝试重新选择 (commit `e5617c7`) + +### 3.2 Backend 系统 + +#### CLISandboxBackend (`box/backend.py`, 411 行) + +Docker / Podman 公共基类: + +``` +start_session(spec): + 1. validate_sandbox_security(spec) + 2. docker/podman run -d --rm --name + --network none (可选) + --cpus/--memory/--pids-limit + --read-only + --tmpfs /tmp + -v :: 主挂载 + -v ::.. 额外挂载 (extra_mounts) + sh -lc 'while true; do sleep 3600; done' + 3. 返回 BoxSessionInfo + +exec(session, spec): + docker/podman exec -e KEY=VAL + sh -lc 'mkdir -p && cd && ' + +start_managed_process(session, spec): + docker/podman exec -i + sh -lc 'mkdir -p && cd && exec ' + 返回 asyncio.subprocess.Process (stdin/stdout PIPE) +``` + +容器以 idle 进程启动,实际命令通过 `docker exec` 执行。`--rm` 确保容器退出时自动清理。 + +**Windows 支持**: backend 内对 Windows 路径处理与 subprocess 调用做了适配(commit `120817a`)。 + +**孤儿清理**: 启动时枚举 `langbot.box=true` 标签的容器,instance_id 不匹配的强制删除。 + +#### NsjailBackend (`box/nsjail_backend.py`, 552 行) + +轻量级 Linux 沙箱(无容器引擎依赖): + +- 使用 namespace 隔离(user/mount/pid/ipc/uts/cgroup/net) +- 挂载宿主 `/usr`/`/lib`/`/bin`/`/sbin` 只读 + 选定 `/etc` 条目 +- 每 session 创建独立目录(workspace/tmp/home) +- 资源限制: cgroup v2 优先,fallback 到 rlimit +- **CLI 兼容**: 通过 `shutil.which(self._nsjail_bin)` 检测系统安装版 nsjail;不存在时再尝试容器内 nsjail(commit `686fcc0`、`feed530`) +- **无自定义镜像**: 使用宿主 OS,`image` 字段固定为 `'host'`,兼容性检查跳过 image + +#### E2BBackend (`box/e2b_backend.py`, 429 行) + +云沙箱后端(commit `75b547f` 引入): + +- 通过 `e2b` SDK 与 E2B 平台通信 +- 配置:`box.e2b.api_key` / `api_url` / `template` +- 支持 `extra_mounts`(commit `0fea9b1` 同步上传文件) +- 无本地容器引擎依赖,适合无 Docker 的部署或 SaaS 多租户场景 +- 不支持自定义 image 字段,由 template 控制 + +### 3.3 Server (`box/server.py`, 508 行) + +单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口): + +1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理所有 action,包括 `INIT` 配置注入、skill store 操作等 +2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws` 与 `/v1/sessions/{id}/managed-process/{pid}/ws`): 双向桥接 WebSocket ↔ 指定 managed process stdin/stdout + +stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attach;Action RPC 走 stdin/stdout。 + +### 3.4 Client (`box/client.py`, 377 行) + +`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用: + +- 25+ 方法对应 25+ 个 RPC action(exec / session / managed-process / skill / status / shutdown) +- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型 +- `execute()` timeout = 300s,其他默认 15s +- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用 + +包级别 `__init__.py` 显式导出:`BoxRuntimeClient`、`ActionRPCBoxClient`(commit `df9c722`)。 + +### 3.5 Actions (`box/actions.py`, 34 行) + +`LangBotToBoxAction` 枚举共定义 **25 个** action: + +| 类别 | Actions | +|------|---------| +| 控制 | `INIT`、`HEALTH`、`STATUS`、`GET_BACKEND_INFO`、`SHUTDOWN` | +| 执行 | `EXEC` | +| Session | `CREATE_SESSION` / `GET_SESSION` / `GET_SESSIONS` / `DELETE_SESSION` | +| Managed Process | `START_MANAGED_PROCESS` / `GET_MANAGED_PROCESS` / `STOP_MANAGED_PROCESS` | +| Skill | `LIST_SKILLS` / `GET_SKILL` / `CREATE_SKILL` / `UPDATE_SKILL` / `DELETE_SKILL` / `SCAN_SKILL_DIRECTORY` / `LIST_SKILL_FILES` / `READ_SKILL_FILE` / `WRITE_SKILL_FILE` / `PREVIEW_SKILL_ZIP` / `INSTALL_SKILL_ZIP` | + +### 3.6 Models (`box/models.py`, 331 行) + +核心数据模型: + +| 模型 | 用途 | +|------|------| +| `BoxNetworkMode` | `OFF` / `ON` | +| `BoxExecutionStatus` | `COMPLETED` / `TIMED_OUT` | +| `BoxHostMountMode` | `NONE` / `READ_ONLY` / `READ_WRITE` | +| `BoxManagedProcessStatus` | `RUNNING` / `EXITED` | +| `BoxMountSpec` | 单条挂载(host_path/mount_path/mode)— **新增** | +| `BoxSpec` | 执行请求;新增 `extra_mounts: list[BoxMountSpec]`、`persistent`、`workspace_quota_mb` | +| `BoxProfile` | 4 个内置 Profile + `locked` frozenset | +| `BoxSessionInfo` | Session 状态(含 backend_name/created_at/last_used_at) | +| `BoxManagedProcessSpec` | 长驻进程参数(process_id/command/args/env/cwd) | +| `BoxManagedProcessInfo` | 进程状态(status/exit_code/stderr_preview/attached) | +| `BoxExecutionResult` | 执行结果(status/exit_code/stdout/stderr/duration_ms) | + +`BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。 + +### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行) + +新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime: + +``` +BoxSkillStore + ├─ list_skills() / get_skill(name) + ├─ create_skill(data) / update_skill(name, data) / delete_skill(name) + ├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表 + ├─ list_skill_files(name, path) 浏览 skill 内文件树 + ├─ read_skill_file(name, path) / write_skill_file(name, path, content) + ├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容 + └─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root + └─ 支持 source_subdir / target_suffix(commit 1aa043f) +``` + +GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone` 拉取,再走 `install_skill_zip` 或 directory 路径。Skill 文件存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),容器内对应 `/workspace/.skills/`。 + +### 3.8 Security (`box/security.py`, 52 行) + +`validate_sandbox_security()`: 黑名单校验 host_path,阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。 + +**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [SaaS 阻塞项 S5](./box-issues.md)。 + +### 3.9 Errors (`box/errors.py`, 33 行) + +| 异常类型 | 含义 | +|----------|------| +| `BoxError` | 基类 | +| `BoxValidationError` | spec/参数校验失败 | +| `BoxBackendUnavailableError` | 无可用 backend | +| `BoxRuntimeUnavailableError` | Runtime 服务不可用 | +| `BoxSessionConflictError` | session 已存在但 spec 不兼容 | +| `BoxSessionNotFoundError` | session 不存在 | +| `BoxManagedProcessConflictError` | session 已有同名 process | +| `BoxManagedProcessNotFoundError` | process 不存在 | + +--- + +## 4. 工具系统集成 + +### 4.1 ToolManager 编排 (`toolmgr.py`) + +``` +ToolManager.initialize() + ├─ NativeToolLoader (exec / read / write / edit / glob / grep) + ├─ PluginToolLoader (插件工具) + ├─ MCPLoader (MCP Server 工具) + ├─ SkillToolLoader (activate 工具 — Tool Call 激活) + └─ SkillAuthoringToolLoader (Skill CRUD) + +工具调用优先级: native → plugin → mcp → skill → skill_authoring +``` + +### 4.2 Native Tools (`native.py`, 846 行) + +| 工具 | 是否在 Box 中执行 | 是否访问宿主文件系统 | +|------|:---:|:---:| +| `exec` | 是 | 否 | +| `read` | **否** | **是** — 直接 `open()` 宿主文件 | +| `write` | **否** | **是** — 直接 `open()` 宿主文件 | +| `edit` | **否** | **是** — 直接 `open()` 宿主文件 | +| `glob` | **否** | **是** — 直接遍历宿主目录 | +| `grep` | **否** | **是** — 直接读宿主文件 | + +**沙箱边界不对称**: 这是刻意的设计权衡 — `read`/`write`/`edit`/`glob`/`grep` 绕过沙箱以获得性能(避免容器 I/O 开销与跨进程拷贝),但意味着 LLM 可以直接读写 `allowed_mount_roots` 下任何文件。Skill 路径经 `_resolve_host_path()` 重写,禁止穿越 `package_root`。 + +**exec 的 Skill 分支**: 命令中引用 `/workspace/.skills/` 的 skill 时: +1. 验证 skill 已激活 +2. 单次 exec 只能引用一个 skill 包 +3. 若 skill 是 Python 项目(有 `requirements.txt` 或 `pyproject.toml`),命令会被 venv bootstrap 包裹(在 skill 挂载点内创建 `.venv`) +4. 调用 `box_service.execute_tool()` → 走默认 session_id 与已组装好的 `extra_mounts`,**不再为每 skill 起独立 session** + +### 4.3 MCP-in-Box (`mcp_stdio.py`, 354 行) + +`BoxStdioSessionRuntime` 让 MCP stdio 服务器在 Box 容器中运行,**共享 session、多 process**模式(commit `529088e`): + +``` +initialize() + 1. 复用/创建共享 session (session_id = _build_box_session_id()) + - persistent=True,长期保持 + 2. workspace.execute_raw(install_cmd) 安装依赖 (可选) + 3. 将每个 MCP server 文件 stage 到 /workspace/.mcp// + 4. workspace.start_managed_process(process_id=) + 5. websocket_client(ws_url) 通过 WS relay 连接 + 6. ClientSession.initialize() MCP 协议握手 +``` + +配置 (`MCPServerBoxConfig`): `network='on'` (MCP 服务器通常需要网络),`host_path_mode='ro'` (默认只读),`startup_timeout_sec=120` (留时间给 pip install)。 + +每条 MCP server 是同一 session 中的一个 managed process,独立的 `process_id`、独立 attach URL,互不阻塞。 + +--- + +## 5. 启动与生命周期 + +### 5.1 启动顺序 (`build_app.py`) + +``` +BuildAppStage.run(ap) + ├─ ... (persistence, models, sessions) ... + │ + ├─ BoxService(ap) + ├─ box_service.initialize() + │ └─ connector.initialize() + │ ├─ [stdio] fork box subprocess + │ ├─ [subprocess+WS] Windows 本地 + │ └─ [remote WS] connect URL + │ └─ 启动心跳 _heartbeat_task + ├─ ap.box_service = box_service + │ + ├─ ToolManager(ap) + ├─ tool_mgr.initialize() + │ ├─ NativeToolLoader (检查 box_service.available) + │ ├─ PluginToolLoader + │ ├─ MCPLoader (Box 可用时,stdio MCP 走沙箱) + │ └─ SkillAuthoringToolLoader + ├─ ap.tool_mgr = tool_mgr + │ + ├─ ... (platform, pipeline) ... + ├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表) + └─ ... (RAG, HTTP, plugins) ... +``` + +BoxService 在 ToolManager **之前**初始化。ToolManager 创建 loader 时检查 `box_service.available`。 + +### 5.2 初始化失败处理 + +```python +try: + await self._runtime_connector.initialize() + self._available = True +except Exception as e: + self._available = False + logger.warning(f"Box runtime unavailable: {e}") +``` + +**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。 + +### 5.3 销毁流程 + +``` +app.dispose() + └─ box_service.dispose() + ├─ connector.dispose() + │ ├─ cancel _heartbeat_task + │ ├─ cancel _handler_task / _ctrl_task + │ └─ terminate subprocess (SIGTERM) + └─ loop.create_task(client.shutdown()) + └─ RPC SHUTDOWN → Box Runtime 清理所有容器 +``` + +Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的直接杀进程更安全。 + +--- + +## 6. 配置 + +### config.yaml (重构后) + +```yaml +box: + enabled: true # 整个 Box 子系统的总开关。设为 false 时: + # - 不连接远程 Box runtime,不 fork 本地 stdio 子进程 + # - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM + # - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝 + # - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响) + # - skill 列表/读取保持只读可用 + # BOX__ENABLED 环境变量可覆盖(统一约定) + backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b' + # 由 box.backend / BOX__BACKEND 选择后端 + runtime: + endpoint: '' # 外部 Runtime 的 WS 基地址 'ws://host:5410' + # 留空 = 本地自管 Runtime + local: + profile: 'default' + image: '' # 覆盖 profile 默认 image + host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径 + default_workspace: '' # 默认 '/default' + skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root) + allowed_mount_roots: # 默认 [''] + - './data/box' + - '/tmp' + workspace_quota_mb: null # 配额覆盖,null = 走 profile + e2b: + api_key: '' # 也可走 E2B_API_KEY 环境变量 + api_url: '' # 自托管 E2B 时填写 + template: '' # 默认 template ID +``` + +> **重大变更**: 较 2026-04-16 文档,配置结构完全重组(commit `eefdea4`)。原字段 `box.profile` / `box.runtime_url` / `box.shared_host_root` / `box.allowed_host_mount_roots` 全部迁入 `box.local.*` 子表,新增 `box.backend` 与 `box.e2b.*` 配置组。 + +### docker-compose.yaml + +`langbot_box` 服务受 compose profile 控制,默认 `docker compose up` **不会**启动它。需要 sandbox 时: + +```bash +docker compose --profile box up # 启动 langbot + langbot_box + plugin runtime +docker compose --profile all up # 同上 +docker compose up # 只起 langbot + plugin runtime (box 关闭) +``` + +若不起 `langbot_box`,需要同步在 `data/config.yaml` 中设 `box.enabled: false`(或 langbot 容器 env 加 `BOX__ENABLED=false`),否则 LangBot 会一直尝试连接不存在的 Box runtime 并报错。 + +```yaml +# langbot_box 的关键 volume +volumes: + - ${LANGBOT_BOX_ROOT}:${LANGBOT_BOX_ROOT} # 工作区挂载(源/目标同路径) + - /var/run/docker.sock:/var/run/docker.sock # Docker backend 复用宿主 docker +``` + +### 关闭/连接失败时的行为矩阵 + +`box.enabled = false` 与"启用但连接失败"在用户可观察行为上**完全一致**——都通过 `BoxService.available = False` 表达,只是 `get_status` 多返回 `enabled` 字段供前端区分文案。 + +| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) | +|---|---|---| +| native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** | +| `activate` / `register_skill` 工具 | 暴露给 LLM | **不暴露** | +| stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio | +| http/sse MCP server | 正常 | 正常(不依赖 Box) | +| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback | +| Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) | +| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 | +| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner | +| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) | + +> 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。 + +### Pipeline 配置 (templates/metadata/pipeline/ai.yaml) + +`local-agent.config.box-session-id-template` 控制 session 作用域,预设: + +- `{launcher_type}_{launcher_id}` — 每个会话 (推荐,默认) +- `{launcher_type}_{launcher_id}_{sender_id}` — 群聊每个用户 +- `{launcher_type}_{launcher_id}_{conversation_id}` — 每个对话上下文 +- `{query_id}` — 每条消息(完全隔离) + +详见 [box-session-scope.md](./box-session-scope.md)。 + +### REST API + +| 端点 | 方法 | 说明 | 前端 | +|------|------|------|:---:| +| `/api/v1/box/status` | GET | 可用性、Profile、后端信息 | ✅ 监控页 | +| `/api/v1/box/sessions` | GET | 活跃 session 列表 | ❌ | +| `/api/v1/box/errors` | GET | 最近 50 条错误 | ❌ | +| `/api/v1/skills` 等 | GET/POST/PUT/DELETE | Skill CRUD、文件浏览、zip/GitHub 安装、preview | ✅ Skill 管理页 | + +前端 `web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx` 已接入 `/api/v1/box/status`,展示 backend 名称、profile 与活跃 session 数。Sessions 与 errors API 仍未接入。 diff --git a/docs/review/box-issues.md b/docs/review/box-issues.md new file mode 100644 index 0000000..15650c7 --- /dev/null +++ b/docs/review/box-issues.md @@ -0,0 +1,76 @@ +# Box 系统 — SaaS 发布前阻塞项 + +> 更新日期: 2026-06-02 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) +> 相关文档: [架构分析](./box-architecture.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md) + +## 范围说明 + +**自部署社区版已具备发布条件**:默认 stdio 模式、box 为可选项;box 关闭 / 不可用时后端、前端、工具、skill、stdio-MCP 均能干净降级(清晰报错、不崩溃);配置向后兼容(旧 `data/config.yaml` 可直接启动);无新增 ORM 模型、无迁移欠债;市场安装失败不会破坏实例。CI 全绿。 + +本清单**只保留发布 SaaS / 多租户 / 公网暴露前必须处理的阻塞项**。社区版(可信、单运营者、内网)不受这些项阻塞——它们的风险面在"不可信调用方能直接触达 Box 控制面"或"多租户共享资源"的场景才成立。 + +## 已解决(社区版发布前) + +| 项 | 处理 | +|----|------| +| 工具调用循环无上限 (原 #13) | `localagent.py` 增加 `MAX_TOOL_CALL_ROUNDS=128`,超限优雅终止(`cafef1a3`) | +| 配额校验同步遍历阻塞事件循环 (原 #10) | `_enforce_workspace_quota` 改 async,工作区遍历走 `asyncio.to_thread`(`cafef1a3`) | +| `host_path` 挂载白名单 (原 #3 的 LangBot 侧) | `pkg/box/service.py` `allowed_mount_roots` 白名单,空列表时拒绝一切宿主挂载 | +| 重复的 `_is_path_under` (原 #12) | 已去重,仅保留一处定义 | +| 重连 / 心跳 / Windows 兼容 / nsjail image 字段 / 前端 Box 状态接入 | 见上一轮 review 记录,均已合入 | + +--- + +## SaaS 阻塞项 + +### S1. Box 控制面无认证 — Critical + +- **位置**: SDK `box/server.py` — Action RPC WS (`/rpc/ws`) 与 managed-process relay (`/v1/sessions/{id}/managed-process/{pid}/ws`) +- **现状**: 两个 WS handler 在 `ws.prepare` 后直接服务,无任何 token / 鉴权;box 默认绑定 `0.0.0.0:5410`。任何能触达该端口者可发起 `EXEC`、创建 session、attach 任意 session 的 managed-process stdin/stdout、甚至 `SHUTDOWN`。LangBot→box 的 INIT 也未下发任何凭证。 +- **缓解现状**: 默认 `docker-compose.yaml` 的 `langbot_box` 未把 5410 发布到宿主(爆炸半径限于内网 bridge);但 box 挂载了 `/var/run/docker.sock`,同网络的任意服务(含被攻破的插件)→ 宿主 root。若运营者把 5410 发布到宿主或独立以 `0.0.0.0` 起 box,则完全裸奔。 +- **要求**: INIT 时下发 token,两个 WS 路由按连接校验(query/header)。这是 SaaS 的**头号**阻塞项。 + +### S2. 无 exec 授权模型(policy.py 死代码) — High + +- **位置**: LangBot `pkg/box/policy.py`(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy` 全项目无引用);`pkg/provider/tools/loaders/native.py`;`pkg/provider/tools/toolmgr.py` +- **现状**: 原生工具(`exec/read/write/edit/glob/grep`)按"box 是否可用"全有或全无地暴露,**无 per-pipeline 的 exec 网关 / 工具白名单 / 沙箱模式 / 权限提升控制**。只要 box 可用,任何使用 local-agent + 函数调用模型的 pipeline 都能跑任意 shell。 +- **要求**: 接入 policy.py(或等价机制),按 pipeline 控制是否暴露 `exec`、可用工具白名单、沙箱网络/只读模式。 + +### S3. 会话资源无界(DoS) — High + +- **#5 session 数量无上限**: SDK `box/runtime.py` `_get_or_create_session` 的 `_sessions` dict 无容量限制——可变 `session_id` 的恶意调用可无限创建容器,耗尽宿主 CPU/内存/PID/磁盘。 +- **#8 无定时回收**: 过期 session 仅在 `_get_or_create_session` 时机会性清理,无独立周期任务;一波创建后转静默会永久泄漏容器。 +- **要求**: `max_sessions` 上限(拒绝或 LRU),加独立周期 reaper(如 60s)。 + +### S4. 工作区配额无内核级限制(TOCTOU) — Med-High + +- **位置**: LangBot `pkg/box/service.py` `_enforce_workspace_quota`(应用层 read-then-check);SDK 侧 `workspace_quota_mb` 仅记录/透传,无 `--storage-opt size=` 等内核/FS 限额 +- **现状**: 执行前后两次检查之间存在竞态窗口;单条命令(`dd`/`fallocate`)可在检查间隙撑爆磁盘,事后检查只能补救。 +- **要求**: Docker `--storage-opt size=` 做内核级限制,或 Redis 原子计数预留式配额。 + +### S5. 挂载校验缺口 — Med-High + +- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX`;`box/backend.py` 的 `extra_mounts` 处理 +- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr`、`/opt`、`/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`,**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦。 +- **要求**: SDK 黑名单加入 `/`(或改白名单);`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。 + +### S6. 容器加固缺失 — Med + +- **位置**: SDK `box/backend.py` 的 `docker run` 组装 +- **现状**: 未设置 `--cap-drop=ALL`、`--security-opt=no-new-privileges`、非 root `--user`;叠加挂载 docker.sock,逃逸面偏大。 +- **要求**: 默认加上上述加固 flag(需回归常用 skill 不被破坏)。 + +### S7. 全局锁内执行慢操作(扩展性) — Med + +- **位置**: SDK `box/runtime.py` `_get_or_create_session`:`self._lock` 持有期间调用 `backend.start_session()`(`docker run` / nsjail 启动 / E2B `Sandbox.create`) +- **影响**: 冷启动(镜像拉取数秒、E2B >1s)期间串行阻塞所有并发请求——多租户负载下整个 Box runtime 停顿。降级表现是延迟而非失败。 +- **要求**: 锁内只做状态检查与注册,容器创建移到锁外。 + +### S8. 其他硬化 / 跟进 — Low + +- **#9** SDK `box/server.py` 直接读 `runtime._sessions` 私有字段、绕过锁,并发下可能读到不一致状态——应加公共访问方法。 +- **#16** `pkg/provider/tools/toolmgr.py` `execute_func_call` 按优先级分发,plugin/MCP 若有同名 `exec/read/write/...` 工具会被静默遮蔽——应加命名空间或冲突告警。 +- **#4** SDK `box/runtime.py` INIT/handshake 与 backend 实例化的残留竞态(仅"纯远程 WS box 先启动、LangBot 后连"场景成立;stdio/compose 路径下 config 经 env 在 spawn 时已就位,无竞态)——应在 INIT 完成前拒绝业务 action。 +- **#11** `extra_mounts` 在容器创建时固定(SDK `runtime.py` 兼容性检查不含 extra_mounts);长生命周期共享 session 后续新激活的 skill 不会挂上(当前缓解:创建时挂上 pipeline 绑定的全部 skill)——动态绑定场景需销毁重建或文档说明。 +- **#21** 集成测试未进 CI:容器实际执行、E2B 真机、managed-process WS attach 仅本地可跑。安全关键路径缺自动化覆盖——SaaS 前建议加 Docker-in-Docker CI stage 或合并前手动 checklist。 diff --git a/docs/review/box-session-scope.md b/docs/review/box-session-scope.md new file mode 100644 index 0000000..bb92265 --- /dev/null +++ b/docs/review/box-session-scope.md @@ -0,0 +1,402 @@ +# Box Session Scope Design + +> Date: 2026-04-18 (last reviewed 2026-06-02) +> Status (2026-06-02): the self-hosted community edition is release-ready (box optional, clean degradation, no migration debt). Tool-call loop cap, async quota scan, and the host_path mount allowlist have landed. Remaining multi-tenant / security hardening is tracked in [box-issues.md](./box-issues.md). +> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk) +> Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md) + +--- + +## 0. Implementation Status (2026-05-19) + +This document was authored as a design proposal. The current `feat/sandbox` branch +has shipped the design largely as written: + +| Item | Status | Notes | +|------|--------|-------| +| `BoxMountSpec` + `BoxSpec.extra_mounts` | ✅ Shipped | SDK `box/models.py` | +| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) | +| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` | +| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` | +| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` | +| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch | +| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` | +| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` | +| Per-tenant / quota integration with templates | ❌ Not started | See [box-tob-analysis.md](./box-tob-analysis.md) | + +The "Phase 2 deferred" note in §10 is **out of date** — MCP unification went in on +the same line. Pipeline-scoped (not user-scoped) MCP container is the realized +behavior: each pipeline's MCP servers share one `mcp-` session, and +user exec sessions use the template-derived id. + +The remaining open work is multi-tenant overlays (tenant_id in session_id, +quota counters keyed by tenant), tracked in the toB analysis doc rather than here. + +--- + +## 1. Problems + +### 1.1 Default exec: per-message containers + +Currently, `BoxService.execute_tool()` sets `session_id = str(query.query_id)` — an +auto-incrementing integer per incoming message. Every user message creates a new sandbox +container. Dependencies installed and in-container state are lost between messages. + +### 1.2 Three isolated container pools + +Default exec, skills, and MCP servers each manage their own containers with +independent session IDs: + +| Path | Session ID | Container | +|--------------|-----------------------------------------------|-------------| +| Default exec | `str(query_id)` (per message) | Ephemeral | +| Skill exec | `skill-{launcher}_{id}-{skill_name}` | Per skill | +| MCP stdio | `mcp-{server_uuid}` | Per server | + +This means a single logical user interaction can spawn 3+ containers that cannot +share state, see each other's files, or reuse installed dependencies. + +### 1.3 Single bind mount limitation + +`BoxSpec` currently supports only **one** `host_path` → `mount_path` bind mount. +This prevents mounting both a default workspace and skill directories into the +same container. + +--- + +## 2. Concept Model + +``` +Platform Message + → Query (query_id: int, auto-increment, per message) + → Session (launcher_type + launcher_id, per chat window) + → Conversation (uuid, per dialogue context within a Session) +``` + +| Concept | Key | Example | Scope | +|---------------|-------------------------------------|----------------------------|------------------------------| +| Query | `query_id` | `42` | Single message | +| Session | `launcher_type` + `launcher_id` | `group_123456` | Chat window (group or PM) | +| Conversation | `conversation_id` (UUID) | `a1b2c3d4-...` | Dialogue context within a Session | +| Sender | `sender_id` | `789` | Individual user | + +Note: in a **group chat**, all users share the same Session (keyed by `group_id`). The +individual sender is tracked as `sender_id` but does not affect Session/Conversation routing. + +--- + +## 3. Target Scenarios + +| # | Scenario | Box Granularity | Desired `session_id` | +|----|--------------------------------|------------------------------------------|---------------------------------------------------------| +| 1 | Personal assistant | 1 Box per user, long-lived | `{launcher_type}_{launcher_id}` | +| 2 | Customer service | 1 Box per customer, cross-pipeline | `{launcher_type}_{launcher_id}` | +| 3 | Internal employee tool | 1 Box per employee | `{launcher_type}_{launcher_id}` | +| 4 | Group chat shared assistant | 1 Box per group | `{launcher_type}_{launcher_id}` | +| 5 | Group chat isolated per user | 1 Box per user within a group | `{launcher_type}_{launcher_id}_{sender_id}` | +| 6 | Teaching (cross-channel) | 1 Box per student across groups/PMs | `{sender_id}` | +| 7 | One-off execution | 1 Box per message (current behavior) | `{query_id}` | +| 8 | Multi-project development | 1 Box per conversation context | `{launcher_type}_{launcher_id}_{conversation_id}` | + +No single fixed granularity covers all scenarios. A template-based approach is needed. + +--- + +## 4. Design Overview + +Two key changes: + +1. **Unified container**: exec, skills, and MCP all share the same container per + session scope. No more separate container pools. +2. **Configurable session scope**: `session_id` is generated from a template with + pipeline variables, configurable per pipeline. + +### 4.1 Unified Container with Multiple Mounts + +A single container per session scope is created on first use. It has: + +- **Primary mount**: default workspace at `/workspace` (from `default_host_workspace`) +- **Skill mounts**: each pipeline-bound skill's `package_root` mounted at + `/workspace/.skills/{skill_name}/` +- **MCP servers**: run as managed processes inside the same container + +``` +Container (session_id = "group_123456") + /workspace/ ← default workspace (bind mount, rw) + /workspace/.skills/web-search/ ← skill package (bind mount, rw) + /workspace/.skills/data-analysis/ ← skill package (bind mount, rw) + [managed process: mcp-server-a] ← MCP server running inside + [managed process: mcp-server-b] ← MCP server running inside +``` + +This requires extending `BoxSpec` to support multiple mounts (see §5). + +### 4.2 Session ID Template + +A new field `box-session-id-template` in the `local-agent` pipeline runner config +controls the session scope: + +```yaml +# templates/metadata/pipeline/ai.yaml (under local-agent.config) +- name: box-session-id-template + label: + en_US: Sandbox Scope + zh_Hans: 沙箱作用域 + description: + en_US: >- + Determines how sandbox environments are shared. Use variables to + control isolation granularity. + zh_Hans: >- + 决定沙箱环境的共享方式。使用变量控制隔离粒度。 + type: select + required: false + default: "{launcher_type}_{launcher_id}" + options: + - value: "{launcher_type}_{launcher_id}" + label: + en_US: Per chat (Recommended) + zh_Hans: 每个会话(推荐) + - value: "{launcher_type}_{launcher_id}_{sender_id}" + label: + en_US: Per user in chat + zh_Hans: 会话中每个用户 + - value: "{launcher_type}_{launcher_id}_{conversation_id}" + label: + en_US: Per conversation context + zh_Hans: 每个对话上下文 + - value: "{query_id}" + label: + en_US: Per message (isolated) + zh_Hans: 每条消息(完全隔离) +``` + +Available template variables (populated by PreProcessor in `query.variables`): + +| Variable | Source | Example | +|---------------------|---------------------------------|----------------------| +| `{launcher_type}` | `query.session.launcher_type` | `person` / `group` | +| `{launcher_id}` | `query.session.launcher_id` | `123456` | +| `{sender_id}` | `query.sender_id` | `789` | +| `{conversation_id}` | `conversation.uuid` | `a1b2c3d4-...` | +| `{query_id}` | `query.query_id` | `42` | + +Default `{launcher_type}_{launcher_id}` covers scenarios 1–4 out of the box. + +--- + +## 5. SDK Changes: Multi-Mount BoxSpec + +### 5.1 Model Extension + +```python +# box/models.py + +class BoxMountSpec(pydantic.BaseModel): + """A single bind mount specification.""" + host_path: str + mount_path: str + mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE + +class BoxSpec(pydantic.BaseModel): + # ... existing fields ... + host_path: str | None = None # Primary mount (backward compat) + host_path_mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE + mount_path: str = DEFAULT_BOX_MOUNT_PATH + extra_mounts: list[BoxMountSpec] = [] # NEW: additional mounts +``` + +`extra_mounts` is additive — the existing `host_path` / `mount_path` pair remains +the primary mount for backward compatibility. + +### 5.2 Backend: Apply Extra Mounts + +```python +# box/backend.py — CLISandboxBackend.start_session() + +# Primary mount (unchanged) +if spec.host_path is not None and spec.host_path_mode != BoxHostMountMode.NONE: + args.extend(['-v', f'{spec.host_path}:{spec.mount_path}:{spec.host_path_mode.value}']) + +# Extra mounts (NEW) +for mount in spec.extra_mounts: + if mount.mode != BoxHostMountMode.NONE: + args.extend(['-v', f'{mount.host_path}:{mount.mount_path}:{mount.mode.value}']) +``` + +Same pattern for nsjail backend. + +--- + +## 6. LangBot Changes + +### 6.1 Session ID Resolution + +In `BoxService.execute_tool()`: + +```python +# Before: +spec_payload.setdefault('session_id', str(query.query_id)) + +# After: +template = (query.pipeline_config or {}).get('ai', {}) \ + .get('local-agent', {}).get('box-session-id-template', + '{launcher_type}_{launcher_id}') +variables = query.variables or {} +session_id = template.format_map(collections.defaultdict( + lambda: 'unknown', variables +)) +spec_payload.setdefault('session_id', session_id) +``` + +### 6.2 Skill Exec: Use Same Container + +Currently `native.py:_invoke_exec` creates a separate `BoxWorkspaceSession` per +skill with `host_path=package_root`. Instead: + +1. Use the **same session_id** as default exec (from the template). +2. Pass the skill's `package_root` as an **extra mount** at + `/workspace/.skills/{skill_name}/` instead of replacing `/workspace`. +3. The container already has the default workspace at `/workspace`. + +```python +# native.py — _invoke_exec, skill branch (REVISED) + +# Same session_id as default exec +session_id = resolve_box_session_id(query) + +spec_payload = { + 'cmd': rewritten_command, + 'workdir': rewritten_workdir, + 'session_id': session_id, + 'extra_mounts': [{ + 'host_path': package_root, + 'mount_path': f'/workspace/.skills/{selected_skill_name}', + 'mode': 'rw', + }], +} +result = await self.ap.box_service.execute_spec_payload(spec_payload, query) +``` + +The virtual path `/workspace/.skills/{name}` no longer needs rewriting at the +command level — it maps directly to the bind mount path inside the container. + +### 6.3 MCP: Use Same Container + +MCP servers should run inside the same container as exec and skills. Changes: + +1. `BoxStdioSessionRuntime` uses the pipeline's session_id template instead of + `mcp-{server_uuid}`. +2. MCP server's working directory is a subdirectory (e.g. `/workspace/.mcp/{name}/`). +3. MCP server's dependencies are mounted or installed into that subdirectory. +4. The MCP server runs as a managed process inside the shared container. + +Since MCP servers start at LangBot boot (not per-query), the session must be +created eagerly. The container will be kept alive by the managed process +exemption in TTL reaping (`runtime.py:259`). + +**Note**: MCP sessions are pipeline-scoped (not per-launcher), so their session_id +should be a **fixed identifier per pipeline** rather than the user-facing template. +This means one shared MCP container per pipeline, with user exec sessions separate. + +Alternatively, in a future iteration, MCP managed processes could be launched +lazily into the user's container on first MCP tool call. This is more complex +but maximizes sharing. For V1, keeping MCP containers at pipeline scope is +simpler and more predictable. + +--- + +## 7. Mount Layout Summary + +### Default exec (no skills activated) + +``` +Container (session_id from template) + /workspace/ ← default_host_workspace (rw) +``` + +### Exec with activated skills + +``` +Container (same session_id) + /workspace/ ← default_host_workspace (rw) + /workspace/.skills/web-search/ ← skill package_root (rw) + /workspace/.skills/data-analysis/ ← skill package_root (rw) +``` + +Extra mounts are **additive** — they are added when the container is first +created (or on the first exec that references a skill). Since Docker bind +mounts are specified at container creation time, skills must be known at +creation time. + +**Resolution**: When creating a container, inject `extra_mounts` for **all +pipeline-bound skills** (from `extensions_preferences`), not just the +currently activated one. This way any skill can be activated later without +recreating the container. + +### MCP servers (V1: pipeline-scoped) + +``` +Container (session_id = "mcp-pipeline-{pipeline_uuid}") + /workspace/ ← MCP shared workspace + /workspace/.mcp/server-a/ ← MCP server A files + /workspace/.mcp/server-b/ ← MCP server B files + [managed process: server-a] + [managed process: server-b] +``` + +--- + +## 8. Data Migration + +Existing pipelines do not have `box-session-id-template`. The backend uses +`.get(..., default)` so missing keys fall back to `{launcher_type}_{launcher_id}`. +This changes behavior from per-message to per-launcher for existing pipelines. + +Recommendation: **accept the behavior change** — per-launcher is the more +intuitive default, and the old per-message behavior was rarely desired. + +--- + +## 9. Cloud Quota Implications + +| Scope | Typical concurrent containers | +|-----------------------------------------------|-------------------------------| +| `{query_id}` (per message) | Many, short-lived | +| `{launcher_type}_{launcher_id}` (per chat) | = active chat count | +| `{sender_id}` (per user) | = active user count | +| `{conversation_id}` (per conversation) | Between per-chat and per-msg | + +With the unified container model, each scope value maps to exactly **one** +container (instead of potentially 3+ per-message). This significantly reduces +resource usage. + +Quota enforcement point: `BoxRuntime._get_or_create_session()` in the SDK. + +--- + +## 10. Implementation Phases + +### Phase 1: Session scope + skill unification (this PR) + +1. **SDK**: Extend `BoxSpec` with `extra_mounts: list[BoxMountSpec]`. +2. **SDK**: Update Docker/nsjail backends to apply extra mounts. +3. **LangBot**: Add `box-session-id-template` to `local-agent` YAML metadata + and default pipeline config JSON. +4. **LangBot**: Update `BoxService.execute_tool()` to use template interpolation. +5. **LangBot**: Update `native.py:_invoke_exec` skill branch to use same + session_id + extra mounts instead of separate `BoxWorkspaceSession`. +6. **LangBot**: On container creation, inject extra mounts for all + pipeline-bound skills. +7. **Frontend**: No code change — `DynamicFormComponent` renders `select` fields. +8. **Tests**: Unit tests for template interpolation and multi-mount specs. + +### Phase 2: MCP unification (future) + +1. Refactor `BoxStdioSessionRuntime` to use pipeline-scoped shared container. +2. MCP servers become managed processes in the shared container. +3. Support multiple concurrent managed processes per container. + +MCP unification is deferred because it requires changes to the managed process +model (currently 1 managed process per session) and has startup ordering +concerns (MCP servers start at boot, before any user query determines +a session_id). diff --git a/docs/review/box-test-coverage.md b/docs/review/box-test-coverage.md new file mode 100644 index 0000000..995e697 --- /dev/null +++ b/docs/review/box-test-coverage.md @@ -0,0 +1,122 @@ +# Box 系统测试覆盖分析 + +> 更新日期: 2026-06-02 +> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) + +--- + +## 1. 测试文件清单 + +### LangBot 仓库 + +| 文件 | 行数 | CI 运行 | 覆盖范围 | +|------|------|---------|---------| +| `tests/unit_tests/box/test_box_connector.py` | 106 | 是 | Connector 传输决策、WS relay URL、dispose、心跳/重连 | +| `tests/unit_tests/box/test_box_service.py` | 1224 | 是 | Service 核心逻辑(最全面) | +| `tests/unit_tests/box/test_workspace.py` | 147 | 是 | WorkspaceSession 路径重写、payload 构建 | +| `tests/unit_tests/provider/test_mcp_box_integration.py` | 707 | 是 | MCP Box 配置、路径重写、payload、shared-session/multi-process、runtime info | +| `tests/unit_tests/provider/test_localagent_sandbox_exec.py` | 444 | 是 | LocalAgent exec 流程、流式、Skill 激活 (Tool Call) | +| `tests/unit_tests/provider/test_tool_manager_native.py` | 249 | 是 | ToolManager 路由、native tool CRUD、路径穿越、6 工具暴露 | +| `tests/unit_tests/provider/test_skill_tools.py` | 582 | 是 | Skill 管理、Tool Call 激活、路径、authoring CRUD | +| `tests/unit_tests/test_skill_service.py` | 396 | 是 | HTTP service:skill CRUD、zip/GitHub install、文件浏览 | +| `tests/unit_tests/test_paths.py` | 23 | 是 | paths 工具 | +| `tests/unit_tests/test_preproc.py` | 134 | 是 | PreProcessor 注入 session 变量、bound skill 解析 | +| `tests/unit_tests/pipeline/test_chat_handler_logging.py` | 78 | 是 | Chat handler 日志相关回归 | +| `tests/integration_tests/box/test_box_integration.py` | 329 | **否** | 真实容器执行、超时、网络隔离 | +| `tests/integration_tests/box/test_box_mcp_integration.py` | 368 | **否** | Managed process、WS attach、shared-session 清理 | + +### SDK 仓库 + +| 文件 | 行数 | CI 运行 | 覆盖范围 | +|------|------|---------|---------| +| `tests/box/test_backend_selection.py` | 255 | 是 | 显式 backend / local 模式探测顺序 / 配置变更触发 reselect | +| `tests/box/test_nsjail_backend.py` | 452 | 是 | nsjail 可用性、安装版 CLI vs 容器内 CLI、session、arg 构建、资源限制 | +| `tests/box/test_e2b_backend.py` | 482 | 是 | E2B SDK mock、session 生命周期、extra_mounts 同步 | +| `tests/box/test_skill_store.py` | 88 | 是 | zip preview/install、基础 file CRUD | + +**总计**: 17 个测试文件, ~6,500 行测试代码; 其中 2 个集成测试(约 700 行)在 CI 中不运行。 + +> 较 2026-04-16 版增加:`test_skill_service.py`、`test_paths.py`、`test_preproc.py`、`test_chat_handler_logging.py` (LangBot),`test_backend_selection.py`、`test_e2b_backend.py`、`test_skill_store.py` (SDK)。`test_nsjail_backend.py` 增加 CLI 兼容性 case (commit `feed530`)。 + +--- + +## 2. 覆盖良好的区域 + +| 区域 | 质量 | 说明 | +|------|------|------| +| BoxRuntime session 管理 | 优秀 | session 复用、冲突检测、TTL 配置、消失 session 重建 | +| BoxService Profile 系统 | 优秀 | 4 个内置 Profile、locked/unlocked 字段、timeout clamp | +| BoxService host mount 安全 | 优秀 | allowed_mount_roots、disallowed_roots、shared host root | +| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 | +| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr | +| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 | +| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 | +| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error | +| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 | +| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file | +| BoxHostMountMode.NONE | 良好 | 枚举校验、workdir 约束 | +| NsjailBackend | 良好 | 可用性、安装版 vs 容器内、session 生命周期、arg 构建、资源限制 | +| E2BBackend | 良好 | mock SDK、session/extra_mounts 同步 | +| Backend selection | 良好 | 显式 backend 优先级、local 探测顺序、配置变更触发 reselect | +| MCP Box 集成 | 良好 | config model、路径重写、payload、shared-session 多 process | +| Native tool loader | 良好 | 6 工具(exec/read/write/edit/glob/grep)、路径穿越拦截 | +| LocalAgent exec 流程 | 良好 | 完整 tool call 循环、流式、system prompt 注入、Tool Call 激活 | +| Skill 系统 | 良好 | 加载、Tool Call 激活、marker、路径解析、authoring CRUD、HTTP service | + +--- + +## 3. 覆盖缺失的区域 + +### 3.1 零测试 / 严重不足 + +| 区域 | 源文件 | 影响 | +|------|--------|------| +| **`security.py`** | SDK `box/security.py` (52 行) | `validate_sandbox_security()` 无任何测试。阻止 `/etc`/`/proc`/Docker socket 等危险挂载的安全函数从未被验证 | +| **`policy.py`** | `pkg/box/policy.py` (98 行) | 三层安全策略无测试(也是死代码) | +| **`skill_store.py` 边缘场景** | SDK `box/skill_store.py` (647 行) vs 测试 88 行 | GitHub 安装路径、`source_subdir` / `target_suffix` 组合、损坏 zip、文件冲突等场景未覆盖 | + +### 3.2 未测试的关键路径 + +| 区域 | 说明 | +|------|------| +| **Session TTL 过期** | 测试配置了 `session_ttl_sec` 但从未推进时间验证过期清理 | +| **并发 session 访问** | 无并发 exec / 并发创建 / race condition 测试 | +| **Container backend (Docker)** | 仅通过集成测试覆盖(CI 不运行),单元测试全用 FakeBackend | +| **E2B 真实 sandbox** | 单测全是 mock,未对接真实 E2B API | +| **BoxRuntime shutdown()** | 在 test cleanup 中调用但未验证行为 | +| **BoxServerHandler 错误路径** | 畸形请求、未知 action 类型 | +| **WS relay** | 仅在集成测试中覆盖(CI 不运行) | +| **NsjailBackend managed process** | 完全未测试 | +| **MCP stdio 完整生命周期** | 依赖安装 → 进程启动 → 健康检查 → 多 process 并发 → 重试 | +| **BoxService start/stop_managed_process** | 单 process 流转有单测,多 process 互不阻塞主要靠集成测试 | +| **重连指数退避** | connector 单测覆盖回调接线,未实际跑完整重连周期 | + +### 3.3 边缘情况缺失 + +| 区域 | 说明 | +|------|------| +| BoxSpec 校验 | 无效 session_id 格式、超长命令、env 特殊字符 | +| BoxSpec.extra_mounts | 重复 mount_path、与 host_path 冲突、绝对 vs 相对路径 | +| BoxExecutionResult | 仅 COMPLETED 和 TIMED_OUT,无 ERROR 状态测试 | +| 多后端 fallback | local 模式探测顺序仅靠 mock,无真实 Docker 不可用 → nsjail 真机 fallback 测试 | +| Profile YAML 加载 | 测试用硬编码字符串,未从真实 config.yaml 加载 | +| INIT 配置变更触发 backend 重建 | 单测仅在初始化场景验证 | + +--- + +## 4. 集成测试 vs CI 的差距 + +CI 仅运行 `tests/unit_tests/`,以下场景**从未在自动化中验证**: + +- 真实容器的创建/执行/销毁 +- 容器网络隔离(`--network none`) +- 容器资源限制生效(cpus/memory/pids_limit) +- Managed process 的 WS 双向 I/O +- 多 process 同 session 并发 I/O +- 孤儿容器清理 +- Session 删除清理容器 +- 进程退出检测 +- E2B 真实 sandbox 行为 + +**建议**: 在 CI 中加一个可选的 Docker-in-Docker 集成测试 stage,至少覆盖核心执行路径(exec / MCP attach / session 销毁)。 diff --git a/docs/review/box-tob-analysis.md b/docs/review/box-tob-analysis.md new file mode 100644 index 0000000..a49544c --- /dev/null +++ b/docs/review/box-tob-analysis.md @@ -0,0 +1,167 @@ +# Box 系统 toB 商业化分析 + +> 更新日期: 2026-06-02 +> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) + +--- + +## 1. 现有优势 + +| 能力 | toB 价值 | 代码位置 | +|------|---------|---------| +| **沙箱隔离执行** | 企业安全运行不受信代码的基础能力 | SDK `box/backend.py` | +| **多后端支持** | 适配不同企业容器基础设施 (Podman/Docker/nsjail/E2B) | SDK `box/runtime.py` `_select_backend()` | +| **E2B 云沙箱** | SaaS / 无 Docker 部署的兜底执行环境 | SDK `box/e2b_backend.py` | +| **连接自愈** | 心跳 + 自动重连,单点 Box runtime 故障可恢复 | `pkg/box/connector.py` `_heartbeat_loop`, `pkg/box/service.py` `_reconnect_loop` | +| **Profile + locked 字段** | 运维锁定安全边界,LLM/用户无法绕过 | `pkg/box/service.py`, SDK `box/models.py` | +| **资源限制** | CPU/内存/PID 数限制防止资源滥用 | SDK `backend.py` `--cpus/--memory/--pids-limit` | +| **Workspace quota** | 磁盘用量控制 | `pkg/box/service.py` `_enforce_workspace_quota` | +| **静默降级** | Box 不可用不影响其他功能,降低部署门槛 | `pkg/box/service.py:78` `_available=False` | +| **孤儿容器清理** | 防止泄漏的容器持续占用资源 | SDK `backend.py` `cleanup_orphaned_containers` | +| **网络隔离** | `--network none` 防止数据外泄 | SDK `backend.py` start_session | +| **只读根文件系统** | `--read-only` 防止容器被持久篡改 | SDK `backend.py` start_session | +| **Host path 白名单** | `allowed_host_mount_roots` 限制可挂载目录 | `pkg/box/service.py` `_validate_host_mount` | + +--- + +## 2. toB 差距分析 + +### 2.1 安全与合规 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **WS relay 认证** | 无认证,任何人可 attach | 至少 token 认证 | **P0** | +| **安全策略** | policy.py 是死代码,实际无细粒度控制 | 工具级 allow/deny、沙箱模式控制 | **P0** | +| **审计日志** | 仅内存中 50 条 `_recent_errors` | 持久化审计:谁何时执行了什么、结果如何 | **P0** | +| **Host path 校验** | 黑名单策略,`/` 未拦截 | 白名单策略,默认拒绝 | **P1** | +| **数据驻留** | 无控制 | GDPR / 等保要求的数据隔离 | **P2** | + +### 2.2 多租户 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **租户隔离** | 无租户概念 | BoxSpec/Profile 绑定 tenant_id | **P0** | +| **RBAC** | 仅 token 认证 | admin/operator/viewer 角色权限 | **P0** | +| **资源配额** | 单一 workspace quota | 每租户 CPU 时间/内存/并发/执行次数配额 | **P1** | +| **Session 隔离** | 所有 session 共享 dict | 按租户分区,互不可见 | **P1** | + +### 2.3 可靠性 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **连接恢复** | 已实现:20s 心跳 + `_reconnect_loop` 指数退避 | 已满足基本要求 | 已有 | +| **Session 清理** | 机会性(仅新建时触发) | 定时清理 + 独立 reaper | **P1** | +| **水平扩展** | 单 Box Runtime 实例 | 多实例负载均衡(按 tenant 路由) | **P1** | +| **优雅降级** | 已有(_available=False) | 已满足基本要求 | 已有 | +| **Backend 自愈** | 已实现:`get_status` 时若 backend 不可用会重新选择 | 已满足基本要求 | 已有 | + +### 2.4 可观测性 + +| 维度 | 现状 | toB 要求 | 优先级 | +|------|------|---------|--------| +| **监控指标** | 无 Prometheus metrics | session 数/执行延迟/资源用量/错误率 | **P1** | +| **结构化日志** | Python logging, 无结构化 | JSON 格式日志,含 trace_id/tenant_id | **P1** | +| **前端面板** | 监控页接入 `/api/v1/box/status`(backend 名 + 活跃 session 数);`sessions` / `errors` 仍未接入 | 完整状态面板 + 历史错误/审计列表 | **P2** | + +--- + +## 3. SaaS 部署架构建议 + +### 3.1 方案 A: 共享 Box Runtime Pool (快速上线) + +``` +LangBot Instance ──> Box Runtime (共享) + ├─ tenant_id 标签隔离 + ├─ Redis 配额计数器 + └─ Container labels: langbot.tenant_id=xxx +``` + +- **优点**: 改动最小,加 tenant_id 到 BoxSpec/labels 即可 +- **缺点**: 容器引擎共享,安全隔离弱 + +### 3.2 方案 B: 每租户 K8s Namespace + gVisor (推荐中期) + +``` +LangBot ──> K8s API + ├─ namespace: tenant-xxx + │ ├─ RuntimeClass: gVisor (runsc) + │ ├─ ResourceQuota + │ └─ NetworkPolicy + └─ namespace: tenant-yyy + └─ ... +``` + +- **优点**: 强隔离(namespace + gVisor),原生 K8s 配额 +- **缺点**: 需要重写 backend 为 K8s Job,部署复杂度高 + +### 3.3 方案 C: K8s Job 直接编排 (长期) + +``` +LangBot ──> K8s Job per execution + ├─ 每次执行创建 Job + ├─ Pod Security Standards + ├─ 自动调度和资源分配 + └─ Job TTL Controller 自动清理 +``` + +- **优点**: 最强隔离,天然水平扩展 +- **缺点**: 冷启动延迟,架构重写 + +**推荐演进路径**: A → B → C + +--- + +## 4. 配额体系建议 + +### 三层配额 + +| 层 | 实现 | 作用 | +|----|------|------| +| **内核层** | Docker `--cpus`/`--memory`/`--storage-opt` | 硬性资源上限,不可绕过 | +| **应用层** | Redis 原子计数器 | 并发 session 数/执行次数/CPU 时间预算 | +| **计费层** | 月度聚合 | 按租户计费(session-hours/execution-count) | + +### Profile 与套餐映射 + +| 套餐 | Profile | locked 字段 | 配额 | +|------|---------|------------|------| +| Free | `offline_readonly` | network, host_path_mode, rootfs | 10 exec/天, 0.5 CPU, 256MB | +| Pro | `default` | (无) | 100 exec/天, 1 CPU, 512MB | +| Enterprise | `network_extended` | (按需) | 无限, 2 CPU, 1GB, 自定义镜像 | + +### TOCTOU 配额修复 + +当前 `_enforce_workspace_quota` 的 TOCTOU 问题可通过两种方式解决: + +1. **预留式配额** (应用层): Redis `INCRBY` 预扣额度 → 执行 → 成功则扣减,失败则回滚 +2. **内核级限制** (Docker): `--storage-opt size=500m` 直接限制容器可写层大小 + +--- + +## 5. 优先实施路线 + +### Phase 1 (2-4 周): 安全基线 + +- [ ] WS relay 加 token 认证 +- [ ] 接入或删除 policy.py +- [x] ~~Box 加重连和心跳~~(已完成,见 [box-issues.md 已解决](./box-issues.md)) +- [ ] 审计日志持久化(至少写文件/数据库) +- [ ] `security.py` 加 `/` 拦截,考虑白名单 +- [ ] INIT 与 backend 初始化顺序整理(避免 backend 在配置到达前实例化) + +### Phase 2 (4-8 周): 多租户基础 + +- [ ] BoxSpec 加 `tenant_id` 字段 +- [ ] 容器 labels 加 tenant 标识 +- [ ] Redis 配额计数器(并发/执行次数/时间) +- [ ] RBAC 基础框架 +- [ ] 定时 session reaper + +### Phase 3 (8-16 周): 生产就绪 + +- [ ] Prometheus metrics exporter +- [ ] 前端 Box 状态面板 +- [ ] K8s backend 支持 (方案 B) +- [ ] 结构化日志 (JSON, trace_id) +- [ ] 水平扩展支持 diff --git a/docs/review/box-vs-plugin-runtime.md b/docs/review/box-vs-plugin-runtime.md new file mode 100644 index 0000000..3e3aa1f --- /dev/null +++ b/docs/review/box-vs-plugin-runtime.md @@ -0,0 +1,222 @@ +# Box Runtime vs Plugin Runtime: 连接架构对比 + +> 更新日期: 2026-06-02 +> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 +> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) + +--- + +## 1. 总体差异 + +| 维度 | Plugin Runtime | Box Runtime | +|------|---------------|-------------| +| **继承关系** | `PluginRuntimeConnector(ManagedRuntimeConnector)` | `BoxRuntimeConnector`(独立类) | +| **传输分支** | 3 条 (Docker/WS, Win32/subprocess+WS, Unix/stdio) | 3 条 (本地 stdio, Win32/subprocess+WS, 远程 WS) | +| **心跳** | 20s ping loop | 20s ping loop(`_heartbeat_loop`) | +| **重连** | WS 模式: sleep 3s → re-initialize | 由 BoxService `_reconnect_loop` 处理,指数退避 | +| **Handler 类型** | `RuntimeConnectionHandler` (1132 行, 25+ action) | 基础 `Handler` + `BoxServerHandler`(SDK 端 25 action) | +| **Client 抽象** | Handler 即 API | 独立 `ActionRPCBoxClient` 封装 Handler | +| **启用/禁用** | `is_enable_plugin` 开关 | 无开关(可用/不可用由初始化结果决定) | +| **初始化失败** | 异常上抛 | 静默降级 `_available=False` | +| **Shutdown** | 直接杀进程 | RPC SHUTDOWN → 清理容器 → 再杀进程 | + +--- + +## 2. 传输决策 + +### Plugin: 3-路决策 + +```python +# pkg/plugin/connector.py:106-165 +if get_platform() == 'docker' or use_websocket_to_connect_plugin_runtime(): + # Docker/WS → ws://langbot_plugin_runtime:5400/control/ws +elif get_platform() == 'win32': + # Windows → 起子进程(无 pipe) + ws://localhost:5400/control/ws +else: + # Unix/Mac → StdioClientController(python -m langbot_plugin.cli rt -s) +``` + +### Box: 3-路决策 + +```python +# pkg/box/connector.py +if self._uses_websocket(): + if platform.get_platform() == 'win32' and not self.configured_runtime_url: + await self._start_subprocess_then_ws() # subprocess + ws://localhost:5410/rpc/ws + else: + await self._connect_remote_ws() # ws://{host}:5410/rpc/ws +else: + await self._start_local_stdio() # StdioClientController +``` + +> 历史:2026-04-16 版本本文档曾把 Box 描述为 2 路决策(缺 Windows 分支)。现已对齐 Plugin 的 3 路设计。 + +### 决策矩阵 + +| 环境 | Plugin | Box | +|------|--------|-----| +| Docker | WS → `:5400` | WS → `:5410/rpc/ws` | +| `--standalone-box` | N/A | WS → `localhost:5410/rpc/ws` | +| Windows 非 Docker | subprocess + WS (`:5400`) | subprocess + WS (`localhost:5410/rpc/ws`) | +| Unix/Mac 非 Docker | stdio | stdio | +| 手动配置 URL | 通过配置项 | WS → 用户配置的 URL | + +--- + +## 3. 连接建立 + +### 同步模式差异 + +**Plugin**: `new_connection_callback` 内直接 ping + await handler_task,`initialize()` 通过 `create_task()` 异步启动,不阻塞等待连接。 + +**Box**: 使用 `asyncio.Event` + `wait_for(timeout=30s)` 模式,`initialize()` 同步等待连接成功或超时。 + +### Box stdio 路径 + +``` +connector._start_local_stdio() + ├─ connected = asyncio.Event() + ├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', N]) + ├─ _ctrl_task = create_task(ctrl.run(callback)) + │ callback: + │ handler = Handler(connection) ← 基础 Handler, 无 disconnect_callback + │ client.set_handler(handler) + │ _handler_task = create_task(handler.run()) + │ call_action(PING, {}) ← 握手, timeout=15s + │ connected.set() ← 通知外层 + │ await _handler_task ← 阻塞直到断开 + └─ await wait_for(connected.wait(), 30s) ← 同步等待 +``` + +### Plugin stdio 路径 + +``` +connector.initialize() + ├─ ctrl = StdioClientController(python, ['-m', 'langbot_plugin.cli', 'rt', '-s']) + ├─ task = ctrl.run(callback) + │ callback: + │ disconnect_callback: + │ [WS] → runtime_disconnect_callback → 重连 + │ [stdio] → 仅日志, 不重连 + │ handler = RuntimeConnectionHandler(conn, disconnect_cb, ap) + │ create_task(handler.run()) + │ handler.ping() ← 握手, timeout=10s + │ await handler_task ← 阻塞直到断开 + ├─ create_task(heartbeat_loop()) ← 20s ping loop + └─ create_task(task) ← 不等待连接 +``` + +--- + +## 4. 心跳与重连 + +### 心跳 + +| 维度 | Plugin | Box | +|------|--------|-----| +| 有心跳? | 是 | 是(`connector.py` `_heartbeat_loop`) | +| 间隔 | 20s | 20s | +| 失败处理 | 仅 DEBUG 日志,不触发重连 | 仅 DEBUG 日志,依赖 connection close 触发重连 | +| 生命周期 | 整个应用生命周期 | 连接建立后启动;`dispose()` 时 cancel | + +### 重连 + +| 维度 | Plugin | Box | +|------|--------|-----| +| Docker/WS 断开 | `runtime_disconnect_callback` → sleep 3s → re-initialize | `runtime_disconnect_callback` → `BoxService._reconnect_loop()`(指数退避) | +| WS 连接失败 | 同上 | 同上;初次失败时 `_available=False`,重连成功后恢复 | +| stdio 断开 | 仅日志,不重连 | 接同样回调;stdio 重连需重新 fork 子进程 | +| 重连退避 | 固定 3s,无 backoff | 指数退避 | + +> 历史:2026-04-16 版本本文档曾把心跳与重连标记为 Box 缺失。这两项已在 commit `2dfd9d5d` / `c6882cf` / `5029d9c` 等修复(详见 [box-issues.md 已解决](./box-issues.md))。 + +--- + +## 5. 共享 IO 层 + +两者复用同一套 SDK IO 基础设施: + +``` +Handler ← ABC (runtime/io/handler.py) + ├── RuntimeConnectionHandler (Plugin 用, LangBot 侧) + ├── ControlConnectionHandler (Plugin 用, SDK 侧) + ├── BoxServerHandler (Box 用, SDK 侧) + └── 匿名 Handler 实例 (Box 用, LangBot 侧) + +Connection ← ABC + ├── StdioConnection (stdio: 16KB chunks, 应用层分帧协议) + └── WebSocketConnection (WS: 64KB chunks, 原生 WS 分帧) + +Controller ← ABC + ├── StdioClientController (fork 子进程, pipe stdin/stdout) + ├── StdioServerController (接管当前进程 stdin/stdout) + ├── WebSocketClientController (连接 WS 服务端) + └── WebSocketServerController (监听 WS 端口) +``` + +共享的核心机制: +- `call_action()` / `call_action_generator()` — RPC 调用/流式调用 +- `ActionRequest` / `ActionResponse` — 请求/响应协议 +- `seq_id` 关联 — 并发请求复用单连接 +- `CommonAction.PING` — 两者都用于初始握手 +- 文件传输 (`send_file`) — Plugin 用,Box 不用 + +--- + +## 6. 端口方案 + +| 服务 | Plugin | Box | +|------|--------|-----| +| Action RPC (stdio) | stdin/stdout | stdin/stdout | +| Action RPC (WS) | `:5400` | `:5410/rpc/ws` | +| 辅助服务 | debug WS `:5401` | managed process WS relay `:5410/v1/sessions/{id}/managed-process/ws` | + +**Box 特点**: 单端口 aiohttp 服务(默认 5410),通过路径区分 Action RPC 和 managed process relay。即使在 stdio 模式,也在 `:5410` 启动 aiohttp 用于 managed process attach。Plugin 在 stdio 模式不开额外端口。 + +--- + +## 7. 销毁对比 + +### Plugin + +```python +dispose(): + if stdio: ctrl.process.terminate() + _dispose_subprocess() # Windows 子进程 + heartbeat_task.cancel() +``` + +### Box + +```python +connector.dispose(): + _handler_task.cancel() + _ctrl_task.cancel() + _subprocess.terminate() + +service.dispose(): + connector.dispose() + loop.create_task(client.shutdown()) # RPC SHUTDOWN → 清理所有容器 +``` + +Box 的 RPC SHUTDOWN 确保容器被正确停止,不会成为孤儿。Plugin 直接杀进程。 + +--- + +## 8. 改进建议 + +### P0 + +1. **两者都加 WS 认证**: 至少 token 认证(INIT 时下发,连接时校验) + +### P1 + +2. **考虑 Box 继承 ManagedRuntimeConnector**: 复用 `_start_runtime_subprocess` / `_wait_until_ready` / `_dispose_subprocess`,减少重复代码 +3. **Plugin 重连加退避**: 固定 3s 无 backoff 可能造成日志洪水,建议向 Box 的指数退避看齐 +4. **统一连接管理模式**: Event-based (Box) vs direct-await (Plugin),考虑收敛为一种 + +### 已完成(自上一轮) + +- ~~Box 加重连~~(commit `2dfd9d5d`) +- ~~Box 加心跳~~(20s loop 与 Plugin 一致) +- ~~Box 加 Windows 支持~~(commit `120817a` / `fafb7a4`) diff --git a/docs/review/mcp-resources-pr-2215-review.md b/docs/review/mcp-resources-pr-2215-review.md new file mode 100644 index 0000000..8e57fc5 --- /dev/null +++ b/docs/review/mcp-resources-pr-2215-review.md @@ -0,0 +1,196 @@ +# MCP Resources PR #2215 Review + +> 更新日期: 2026-06-29 +> 分支: `mcp_resources` +> PR: langbot-app/LangBot#2215 +> 主题: MCP Resources 在 LangBot 中的产品价值、AgentRunner 集成方式与后续架构方向 + +## 结论 + +PR #2215 对 LangBot 有明确价值:它补齐了 MCP 协议中 Resources 这一重要能力,让 MCP server 不再只暴露 tools,也可以暴露文档、代码片段、配置、日志、图片等上下文资源。管理端可以发现和预览资源,Agent 也可以通过当前实现按需列出和读取资源。 + +但当前 AgentRunner 层的接入方式更接近一个可用的第一阶段方案,而不是最终架构。现在 MCP Resources 被包装成两个 synthetic tools: + +- `langbot_mcp_list_resources` +- `langbot_mcp_read_resource` + +这让模型可以通过 function calling 主动探索资源,落地成本低,也复用了已有 `ToolManager` / `LocalAgentRunner` 的工具调用链路。不过从 MCP 规范和主流实现来看,Resources 更适合作为一种一等上下文来源,而不是长期隐藏在工具列表里。 + +建议保留当前 synthetic tools 作为探索能力,同时把后续主线设计调整为:MCP Resources 是 pipeline / conversation / message 级别可选择、可固定、可审计的上下文输入。 + +## 当前实现判断 + +当前 AgentRunner 集成路径如下: + +```text +Pipeline 绑定 MCP server + -> query.variables['_pipeline_bound_mcp_servers'] + -> Preproc 为 local-agent 加载工具 + -> ToolManager.get_all_tools() + -> MCPLoader 注入 synthetic resource tools + -> LocalAgentRunner 将工具 schema 传给模型 + -> 模型发起 list/read tool call + -> ToolManager.execute_func_call() + -> MCPLoader 调 MCP session.list_resources/read_resource + -> tool result 回灌给模型 +``` + +这个路径的优点是: + +- 复用现有工具调用机制,改动范围小。 +- Agent 可以按需探索资源,不需要每轮预先读取所有资源。 +- 可以沿用 pipeline 绑定的 MCP server 范围,避免越权读取未绑定 server。 +- 对已有 MCP tools 行为影响较小。 + +主要问题是: + +- Resources 在语义上被降级成 tools,和 MCP 规范里的 resource primitive 不完全一致。 +- 模型必须先理解并主动调用 `list/read`,资源不会自然成为上下文。 +- pipeline 不能配置“默认携带某些资源”或“本轮附加某些资源”。 +- UI 资源 tab 目前是管理端预览能力,和 Agent 上下文选择没有打通。 +- 对 blob、图片、大文件、结构化资源的处理还比较粗糙。 +- 缺少 resource templates、订阅更新、缓存、chunk、token budget、trace 与审计策略。 + +## 主流项目做法 + +### MCP 官方规范 + +MCP Resources 是 server 暴露上下文数据的协议能力。规范没有要求 resources 必须以 tool call 形式给模型使用,而是把如何选择、过滤、读取和纳入上下文交给 Host application。 + +这意味着比较正统的集成方式是:LangBot 作为 Host,在 pipeline、会话或消息层决定哪些 resources 进入模型上下文。 + +参考: https://modelcontextprotocol.io/specification/2025-06-18/server/resources + +### VS Code Copilot + +VS Code 把 MCP Resources 做成 chat context 的一部分。用户可以通过 `Add Context > MCP Resources` 或命令浏览 MCP resources,并把选中的资源附加到一次 chat request。 + +这是目前最值得 LangBot 参考的产品形态:资源不是模型工具,而是用户和 Host 可控的上下文附件。 + +参考: https://code.visualstudio.com/docs/agent-customization/mcp-servers + +### Anthropic SDK + +Anthropic 的 client-side MCP helpers 提供资源读取和转换能力,例如把 MCP resource 转为 Claude message content 或 file。也就是说,应用先读取 resource,再显式放进模型消息。 + +这同样是 application-owned context injection,而不是把 resource 伪装成模型工具。 + +参考: https://platform.claude.com/docs/en/agents-and-tools/mcp-connector + +### LangChain MCP Adapters + +LangChain 把 MCP Resources 更像 data loader / document input 来处理,可以把资源加载成 `Blob`,再进入 LangChain 的文档、检索或上下文处理链路。 + +这说明 Resources 很适合作为知识源、文档源或上下文源,而不只是即时工具调用。 + +参考: https://docs.langchain.com/oss/python/langchain/mcp + +### OpenAI Agents SDK + +OpenAI Agents SDK 主路径仍偏向 MCP tools,但底层 MCP server API 已经有 `list_resources`、`list_resource_templates`、`read_resource` 等能力。当前形态说明 resources 是 client 能力,但并未默认变成 agent-visible tools。 + +参考: https://openai.github.io/openai-agents-python/mcp/ + +### Cline + +Cline 会拉取 MCP tools、resources、resourceTemplates、prompts,并通过类似 `access_mcp_resource` 的内置访问方式让模型读取资源。这个方向和 LangBot 当前 synthetic tools 比较接近。 + +这种模式适合让 Agent 自主探索,但更像 Host 自定义的模型访问协议,不应成为唯一集成路径。 + +参考: https://github.com/cline/cline/blob/main/src/services/mcp/McpHub.ts + +## 建议架构方向 + +### 1. 保留探索型工具 + +保留当前两个 synthetic tools: + +- `langbot_mcp_list_resources` +- `langbot_mcp_read_resource` + +它们适合处理“用户没有显式选择资源,但 Agent 判断需要探索 MCP server 上下文”的场景。后续可以优化工具描述、返回格式、资源大小限制和错误信息。 + +### 2. 增加一等 Resource Context + +新增一个 Host 层资源上下文概念,例如: + +```text +PipelineResourceBinding +ConversationResourceAttachment +MessageResourceAttachment +``` + +Preproc 或独立的 `ResourceContextProvider` 在模型调用前读取这些资源,按 MIME 类型、大小、token budget 转为模型可消费的上下文。 + +### 3. 打通 UI 与 Agent 上下文 + +当前 MCP 详情页的 Resources tab 可以继续作为资源发现和预览入口。建议增加操作: + +- 添加到本轮上下文 +- 固定到当前 pipeline +- 固定到当前 bot / conversation +- 查看资源读取历史和错误 + +这样 UI 资源管理能力才能真正影响 Agent 行为。 + +### 4. 支持 resource templates + +MCP resource templates 允许 server 暴露参数化资源,例如: + +```text +repo://{owner}/{repo}/file/{path} +log://{service}/{date} +``` + +LangBot 后续应支持模板发现、参数填写、实例化和绑定。否则只能使用静态 resources,覆盖面会受限。 + +### 5. 增加资源处理策略 + +建议补齐: + +- 文本资源 token budget 与截断策略。 +- 大文件 chunk 与摘要策略。 +- 图片/blob 的模型能力判断与 fallback。 +- MIME 类型白名单与安全限制。 +- 缓存与过期策略。 +- `resources/listChanged` 或订阅更新。 +- resource read trace,便于审计 Agent 读取了什么上下文。 + +## 推荐落地顺序 + +### Phase 1: 完成当前 PR 可用性 + +- 保留 synthetic tools。 +- 明确文档说明当前 Agent 集成是 tool-mediated。 +- 完善资源工具描述,降低模型误用概率。 +- 给 read/list 增加大小限制和更清晰的 MIME 处理。 +- 前端 Resources tab 与 Tools tab 分离,保持管理端清晰。 + +### Phase 2: 做 Host-owned context attachments + +- 在 pipeline 或 conversation 层新增 resource attachment 配置。 +- Preproc 读取已绑定 resources,注入模型上下文。 +- UI 支持“添加到上下文 / 固定到 pipeline”。 +- 记录每轮实际注入的 resource URI 和 token 消耗。 + +### Phase 3: 做完整 MCP Resources 能力 + +- 支持 resource templates。 +- 支持资源订阅更新。 +- 支持 chunk、summary、RAG 化接入。 +- 为 DifyAgentRunner、LocalAgentRunner 等不同 runner 定义统一资源上下文接口。 + +## 最终建议 + +PR #2215 可以作为 MCP Resources 的第一阶段实现继续推进。它让 LangBot 快速拥有“资源发现、预览、按需读取”的闭环,也给 Agent 探索资源提供了可运行路径。 + +但在正式设计上,不建议把 “Resources == Tools” 固化为长期抽象。LangBot 更应该把 MCP Resources 定位为上下文来源,与 tools、prompts、knowledge base 并列: + +```text +Tools -> Agent 可以执行的动作 +Resources -> Host/用户/Agent 可以选择的上下文数据 +Prompts -> 可复用的任务模板 +Knowledge -> 可检索、可索引的长期知识 +``` + +这样既尊重 MCP 协议语义,也能让 LangBot 在 Agent 工作流、企业知识接入和多 MCP server 管理上走得更稳。 diff --git a/docs/service-api-openapi.json b/docs/service-api-openapi.json new file mode 100644 index 0000000..032ba30 --- /dev/null +++ b/docs/service-api-openapi.json @@ -0,0 +1,1944 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "LangBot API with API Key Authentication", + "description": "LangBot external service API documentation. These endpoints support API Key authentication \nfor external systems to programmatically access LangBot resources.\n\n**Authentication Methods:**\n- User Token (via `Authorization: Bearer `)\n- API Key (via `X-API-Key: ` or `Authorization: Bearer `)\n\nAll endpoints documented here accept BOTH authentication methods.\n", + "version": "4.5.0", + "contact": { + "name": "LangBot", + "url": "https://langbot.app" + }, + "license": { + "name": "Apache-2.0", + "url": "https://github.com/langbot-app/LangBot/blob/master/LICENSE" + } + }, + "servers": [ + { + "url": "http://localhost:5300", + "description": "Local development server" + } + ], + "tags": [ + { + "name": "Models - LLM", + "description": "Large Language Model management operations" + }, + { + "name": "Models - Embedding", + "description": "Embedding model management operations" + }, + { + "name": "Bots", + "description": "Bot instance management operations" + }, + { + "name": "Pipelines", + "description": "Pipeline configuration management operations" + } + ], + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "paths": { + "/api/v1/provider/models/llm": { + "get": { + "tags": [ + "Models - LLM" + ], + "summary": "List all LLM models", + "description": "Retrieve a list of all configured LLM models", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMModel" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Models - LLM" + ], + "summary": "Create a new LLM model", + "description": "Create and configure a new LLM model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LLMModelCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Model created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/api/v1/provider/models/llm/{model_uuid}": { + "get": { + "tags": [ + "Models - LLM" + ], + "summary": "Get a specific LLM model", + "description": "Retrieve details of a specific LLM model by UUID", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/LLMModel" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Models - LLM" + ], + "summary": "Update an LLM model", + "description": "Update the configuration of an existing LLM model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LLMModelUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Model updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Models - LLM" + ], + "summary": "Delete an LLM model", + "description": "Remove an LLM model from the system", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Model deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/provider/models/llm/{model_uuid}/test": { + "post": { + "tags": [ + "Models - LLM" + ], + "summary": "Test an LLM model", + "description": "Test the connectivity and functionality of an LLM model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Model configuration to test" + } + } + } + }, + "responses": { + "200": { + "description": "Model test successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/api/v1/provider/models/embedding": { + "get": { + "tags": [ + "Models - Embedding" + ], + "summary": "List all embedding models", + "description": "Retrieve a list of all configured embedding models", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmbeddingModel" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Models - Embedding" + ], + "summary": "Create a new embedding model", + "description": "Create and configure a new embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddingModelCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Model created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/provider/models/embedding/{model_uuid}": { + "get": { + "tags": [ + "Models - Embedding" + ], + "summary": "Get a specific embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/EmbeddingModel" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Models - Embedding" + ], + "summary": "Update an embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddingModelUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Model updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Models - Embedding" + ], + "summary": "Delete an embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Model deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/provider/models/embedding/{model_uuid}/test": { + "post": { + "tags": [ + "Models - Embedding" + ], + "summary": "Test an embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Model test successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/platform/bots": { + "get": { + "tags": [ + "Bots" + ], + "summary": "List all bots", + "description": "Retrieve a list of all configured bot instances", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "bots": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Bot" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Bots" + ], + "summary": "Create a new bot", + "description": "Create and configure a new bot instance", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Bot created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/platform/bots/{bot_uuid}": { + "get": { + "tags": [ + "Bots" + ], + "summary": "Get a specific bot", + "description": "Retrieve details of a specific bot instance", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Bot UUID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "bot": { + "$ref": "#/components/schemas/Bot" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Bots" + ], + "summary": "Update a bot", + "description": "Update the configuration of an existing bot instance", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Bot updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Bots" + ], + "summary": "Delete a bot", + "description": "Remove a bot instance from the system", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Bot deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/platform/bots/{bot_uuid}/logs": { + "post": { + "tags": [ + "Bots" + ], + "summary": "Get bot event logs", + "description": "Retrieve event logs for a specific bot", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "from_index": { + "type": "integer", + "default": -1, + "description": "Starting index for logs (-1 for latest)" + }, + "max_count": { + "type": "integer", + "default": 10, + "description": "Maximum number of logs to retrieve" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "type": "object" + } + }, + "total_count": { + "type": "integer" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/pipelines": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "List all pipelines", + "description": "Retrieve a list of all configured pipelines", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "sort_by", + "in": "query", + "schema": { + "type": "string", + "default": "created_at" + }, + "description": "Field to sort by" + }, + { + "name": "sort_order", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + }, + "description": "Sort order" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "pipelines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pipeline" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Pipelines" + ], + "summary": "Create a new pipeline", + "description": "Create and configure a new pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Pipeline created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/pipelines/_/metadata": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "Get pipeline metadata", + "description": "Retrieve metadata and configuration options for pipelines", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "configs": { + "type": "object" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/pipelines/{pipeline_uuid}": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "Get a specific pipeline", + "description": "Retrieve details of a specific pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "pipeline": { + "$ref": "#/components/schemas/Pipeline" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Pipelines" + ], + "summary": "Update a pipeline", + "description": "Update the configuration of an existing pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Pipeline updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Pipelines" + ], + "summary": "Delete a pipeline", + "description": "Remove a pipeline from the system", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Pipeline deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/pipelines/{pipeline_uuid}/extensions": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "Get pipeline extensions", + "description": "Retrieve extensions and plugins configured for a pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Pipelines" + ], + "summary": "Update pipeline extensions", + "description": "Update the extensions configuration for a pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Extensions updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API Key authentication using X-API-Key header.\nExample: `X-API-Key: lbk_your_api_key_here`\n" + }, + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Bearer token authentication. Can be either a user JWT token or an API key.\nExample: `Authorization: Bearer `\n" + } + }, + "parameters": { + "ModelUUID": { + "name": "model_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Model UUID" + } + }, + "schemas": { + "LLMModel": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "GPT-4" + }, + "description": { + "type": "string", + "example": "OpenAI GPT-4 model" + }, + "requester": { + "type": "string", + "example": "openai-chat-completions" + }, + "requester_config": { + "type": "object", + "properties": { + "model": { + "type": "string", + "example": "gpt-4" + }, + "args": { + "type": "object" + } + } + }, + "api_keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "keys": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "abilities": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "chat", + "vision" + ] + }, + "extra_args": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "LLMModelCreate": { + "type": "object", + "required": [ + "name", + "requester", + "requester_config", + "api_keys" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "abilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "LLMModelUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "abilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "EmbeddingModel": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "extra_args": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "EmbeddingModelCreate": { + "type": "object", + "required": [ + "name", + "requester", + "requester_config", + "api_keys" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "EmbeddingModelUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "Bot": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "adapter": { + "type": "string", + "example": "telegram" + }, + "config": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "BotCreate": { + "type": "object", + "required": [ + "name", + "adapter", + "config" + ], + "properties": { + "name": { + "type": "string" + }, + "adapter": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "BotUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "Pipeline": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object" + }, + "is_default": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "PipelineCreate": { + "type": "object", + "required": [ + "name", + "config" + ], + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "PipelineUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "SuccessResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "nullable": true + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": -1 + }, + "msg": { + "type": "string", + "example": "Error message" + } + } + } + }, + "responses": { + "UnauthorizedError": { + "description": "Authentication required or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "no_auth": { + "value": { + "code": -1, + "msg": "No valid authentication provided (user token or API key required)" + } + }, + "invalid_key": { + "value": { + "code": -1, + "msg": "Invalid API key" + } + } + } + } + } + }, + "NotFoundError": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": -1, + "msg": "Resource not found" + } + } + } + }, + "InternalServerError": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": -2, + "msg": "Internal server error" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/examples/http-bot/README.md b/examples/http-bot/README.md new file mode 100644 index 0000000..b04387d --- /dev/null +++ b/examples/http-bot/README.md @@ -0,0 +1,75 @@ +# HTTP Bot Adapter — Reference Clients + +> English | [中文](./README.zh.md) + +Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter. +They show the whole loop: signing a request, pushing a message, and receiving +multi-part replies on a callback endpoint. + +Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot). +Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json). + +## Files + +| File | What it is | +|---|---| +| `playground.py` | **Interactive browser debug console** — a single-file web app you open in a browser to chat with a running `http_bot` bot and watch signing / 202 / callbacks live. Zero extra deps. | +| `client.py` | Python client + Flask callback receiver (`pip install flask requests`). | +| `client.ts` | TypeScript/Node 18+ client + callback receiver, **zero deps** (`npx tsx client.ts`). | + +All three implement the identical HMAC-SHA256 scheme +(`sha256=hex(HMAC(secret, "{timestamp}." + body))`) — verified byte-for-byte +against the adapter. + +## Interactive playground (recommended first run) + +A self-contained web console: type a message in your browser, it is signed and +POSTed to a **running** `http_bot` bot, and the bot's replies stream back into +the page — with a debug panel showing the signature, the `202` ack, and each +callback's `sequence` / signature-verification. + +```bash +# From the LangBot repo root, with the backend already running: +PUBLIC_IP= ./.venv/bin/python examples/http-bot/playground.py +# then open http://:8920/ +``` + +On startup it reads the LangBot API key + the `http_bot` bot from +`data/langbot.db`, and configures that bot (inbound/outbound secret + +`callback_url`) to point back at itself via the LangBot API — the bot reloads +live, no restart needed. Requirements: an enabled `http_bot` bot bound to a +working pipeline, and port `8920` reachable from your browser. + +Env knobs: `PUBLIC_IP` (default `127.0.0.1`), `PLAYGROUND_PORT` (default `8920`). + +## Headless clients + +```bash +# Python — Terminal 1: callback receiver (your callback_url target) +python client.py serve --port 8900 --secret SHARED_SECRET + +# Python — Terminal 2: push a message +python client.py push --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 --text "hello" + +# blocking sync mode +python client.py sync --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 --text "hello" + +# reset a session +python client.py reset --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 +``` + +```bash +# TypeScript (Node 18+) +npx tsx client.ts serve 8900 SHARED_SECRET +npx tsx client.ts push https://your-langbot/bots/ SHARED_SECRET ticket-1 "hello" +``` + +When the bot replies, the receiver prints each part with its `sequence` and an +`[FINAL]` marker on the last one — that's the 1→M multi-reply model in action. + +> The bot's `callback_url` must be reachable from LangBot. For local testing, +> expose your receiver with a tunnel (cloudflared / ngrok) and set that URL in +> the bot config. diff --git a/examples/http-bot/README.zh.md b/examples/http-bot/README.zh.md new file mode 100644 index 0000000..1baf812 --- /dev/null +++ b/examples/http-bot/README.zh.md @@ -0,0 +1,71 @@ +# HTTP Bot 适配器 —— 参考客户端 + +> [English](./README.md) | 中文 + +面向 LangBot **HTTP Bot** 平台适配器的极简、低依赖客户端示例。 +它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收 +1→M 的多段回复。 + +完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。 +机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。 + +## 文件清单 + +| 文件 | 是什么 | +|---|---| +| `playground.py` | **浏览器交互式调试台** —— 单文件 Web 应用,在浏览器里和一个运行中的 `http_bot` bot 对话,实时观察签名 / 202 / 回调。零额外依赖。 | +| `client.py` | Python 客户端 + Flask 回调接收端(`pip install flask requests`)。 | +| `client.ts` | TypeScript/Node 18+ 客户端 + 回调接收端,**零依赖**(`npx tsx client.ts`)。 | + +三者实现完全一致的 HMAC-SHA256 签名方案 +(`sha256=hex(HMAC(secret, "{timestamp}." + body))`)—— 已与适配器逐字节比对验证。 + +## 交互式 playground(推荐先跑这个) + +一个自包含的 Web 控制台:在浏览器里输入消息,它会被签名并 POST 给一个 +**运行中**的 `http_bot` bot,bot 的回复会流式回到页面上 —— 调试面板会显示 +签名、`202` 确认,以及每条回调的 `sequence` / 签名验证结果。 + +```bash +# 在 LangBot 仓库根目录、后端已启动的前提下: +PUBLIC_IP=<你的主机IP> ./.venv/bin/python examples/http-bot/playground.py +# 然后打开 http://<你的主机IP>:8920/ +``` + +启动时它会从 `data/langbot.db` 读取 LangBot API key 和 `http_bot` bot, +并通过 LangBot API 把该 bot 配好(入站/出站密钥 + `callback_url`)指回自己 —— +bot 会热加载,无需重启。前提:有一个已启用、绑定了可用 pipeline 的 +`http_bot` bot,且端口 `8920` 能从你的浏览器访问到。 + +可调环境变量:`PUBLIC_IP`(默认 `127.0.0.1`)、`PLAYGROUND_PORT`(默认 `8920`)。 + +## 无头客户端 + +```bash +# Python —— 终端 1:回调接收端(你的 callback_url 指向它) +python client.py serve --port 8900 --secret SHARED_SECRET + +# Python —— 终端 2:推送一条消息 +python client.py push --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 --text "hello" + +# 阻塞式同步模式 +python client.py sync --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 --text "hello" + +# 重置一个会话 +python client.py reset --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-1 +``` + +```bash +# TypeScript(Node 18+) +npx tsx client.ts serve 8900 SHARED_SECRET +npx tsx client.ts push https://your-langbot/bots/ SHARED_SECRET ticket-1 "hello" +``` + +当 bot 回复时,接收端会逐条打印,带上各自的 `sequence`,并在最后一条标记 +`[FINAL]` —— 这就是 1→M 多段回复模型的实际效果。 + +> bot 的 `callback_url` 必须能从 LangBot 访问到。本地测试时,可用隧道 +> (cloudflared / ngrok)把你的接收端暴露出去,并把那个 URL 填进 bot 配置。 diff --git a/examples/http-bot/client.py b/examples/http-bot/client.py new file mode 100644 index 0000000..cbf9eff --- /dev/null +++ b/examples/http-bot/client.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""LangBot HTTP Bot adapter — reference client (Python). + +Two things in one file: + +1. ``push()`` / ``push_sync()`` — send a message into a LangBot ``http_bot`` bot. +2. A tiny Flask callback receiver that verifies signatures and prints replies, + so you can watch N->1 aggregation and 1->M multi-reply working live. + +Usage +----- + pip install flask requests + + # Terminal 1 — start the callback receiver (this is your callback_url): + python client.py serve --port 8900 --secret SHARED_SECRET + + # Terminal 2 — push a message (async; reply lands on the receiver): + python client.py push \ + --url https://your-langbot/bots/ \ + --secret SHARED_SECRET \ + --session ticket-10293 \ + --text "Export keeps failing on the dashboard." + + # Or push and block for the collapsed reply (sync convenience mode): + python client.py sync --url https://your-langbot/bots/ \ + --secret SHARED_SECRET --session ticket-10293 --text "hi" + +The signing scheme is HMAC-SHA256 over ``"{timestamp}." + raw_body``; see +``sign()`` below — it is intentionally tiny and easy to port. +""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import sys +import time +import uuid + +HEADER_TIMESTAMP = 'X-LB-Timestamp' +HEADER_SIGNATURE = 'X-LB-Signature' +HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key' +REPLAY_WINDOW = 300 + + +def sign(secret: str, body: bytes, timestamp: int | None = None) -> tuple[str, str]: + """Return (timestamp, signature) for *body*.""" + ts = str(timestamp if timestamp is not None else int(time.time())) + mac = hmac.new(secret.encode(), f'{ts}.'.encode() + body, hashlib.sha256) + return ts, 'sha256=' + mac.hexdigest() + + +def verify(secret: str, body: bytes, timestamp: str | None, signature: str | None) -> bool: + """Verify an inbound signature (used by the callback receiver).""" + if not timestamp or not signature: + return False + try: + if abs(int(time.time()) - int(float(timestamp))) > REPLAY_WINDOW: + return False + except ValueError: + return False + _, expected = sign(secret, body, int(float(timestamp))) + return hmac.compare_digest(expected, signature) + + +def _post(url: str, secret: str, payload: dict, idempotency: bool = True): + import requests + + body = json.dumps(payload, ensure_ascii=False).encode() + ts, sig = sign(secret, body) + headers = { + 'Content-Type': 'application/json', + HEADER_TIMESTAMP: ts, + HEADER_SIGNATURE: sig, + } + if idempotency: + headers[HEADER_IDEMPOTENCY] = uuid.uuid4().hex + resp = requests.post(url, data=body, headers=headers, timeout=30) + print(f'-> {resp.status_code} {resp.text}') + return resp + + +def push(url: str, secret: str, session: str, text: str, session_type: str = 'person'): + """Fire-and-collect: returns 202 immediately; reply arrives on your callback.""" + payload = { + 'session_id': session, + 'session_type': session_type, + 'message': [{'type': 'Plain', 'text': text}], + } + return _post(url.rstrip('/'), secret, payload) + + +def push_sync(url: str, secret: str, session: str, text: str, session_type: str = 'person'): + """Blocking convenience: POST to /sync and get the collapsed reply back.""" + payload = { + 'session_id': session, + 'session_type': session_type, + 'message': [{'type': 'Plain', 'text': text}], + } + resp = _post(url.rstrip('/') + '/sync', secret, payload, idempotency=False) + return resp + + +def reset(url: str, secret: str, session: str, session_type: str = 'person'): + """Reset a session's conversation (next message starts fresh).""" + payload = {'session_id': session, 'session_type': session_type} + return _post(url.rstrip('/') + '/reset', secret, payload, idempotency=False) + + +def serve(port: int, secret: str): + """Run a callback receiver that verifies signatures and prints replies.""" + from flask import Flask, request + + app = Flask(__name__) + + @app.route('/', methods=['POST']) + def recv(): + raw = request.get_data() + ok = verify(secret, raw, request.headers.get(HEADER_TIMESTAMP), request.headers.get(HEADER_SIGNATURE)) + if not ok: + print('!! signature verification FAILED — rejecting') + return {'error': 'bad signature'}, 401 + data = json.loads(raw) + text_parts = [c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain'] + marker = 'FINAL' if data.get('is_final') else 'part ' + print( + f'[{marker}] session={data["session_id"]} seq={data["sequence"]} ' + f'reply_to={data.get("reply_to")}: {" ".join(text_parts)}' + ) + return {'ok': True} + + print(f'callback receiver listening on http://0.0.0.0:{port}/ (Ctrl-C to stop)') + app.run(host='0.0.0.0', port=port) + + +def main(argv=None): + p = argparse.ArgumentParser(description='LangBot HTTP Bot reference client') + sub = p.add_subparsers(dest='cmd', required=True) + + sp = sub.add_parser('serve', help='run the callback receiver') + sp.add_argument('--port', type=int, default=8900) + sp.add_argument('--secret', required=True) + + for name in ('push', 'sync', 'reset'): + c = sub.add_parser(name) + c.add_argument('--url', required=True, help='https://host/bots/') + c.add_argument('--secret', required=True) + c.add_argument('--session', required=True) + c.add_argument('--session-type', default='person', choices=['person', 'group']) + if name != 'reset': + c.add_argument('--text', required=True) + + args = p.parse_args(argv) + if args.cmd == 'serve': + serve(args.port, args.secret) + elif args.cmd == 'push': + push(args.url, args.secret, args.session, args.text, args.session_type) + elif args.cmd == 'sync': + push_sync(args.url, args.secret, args.session, args.text, args.session_type) + elif args.cmd == 'reset': + reset(args.url, args.secret, args.session, args.session_type) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/examples/http-bot/client.ts b/examples/http-bot/client.ts new file mode 100644 index 0000000..ec12182 --- /dev/null +++ b/examples/http-bot/client.ts @@ -0,0 +1,123 @@ +/** + * LangBot HTTP Bot adapter — reference client (TypeScript / Node 18+). + * + * Zero runtime dependencies (uses global `fetch`, `crypto`, and `http`). + * + * - `push()` : fire-and-collect; reply lands on your callback URL. + * - `pushSync()` : POST /sync and await the collapsed reply. + * - `reset()` : reset a session's conversation. + * - `startReceiver()` : a callback server that verifies signatures and logs + * replies, so you can watch N->1 and 1->M live. + * + * Run the demos: + * npx tsx client.ts serve 8900 SHARED_SECRET + * npx tsx client.ts push https://host/bots/ SHARED_SECRET ticket-1 "hello" + * npx tsx client.ts sync https://host/bots/ SHARED_SECRET ticket-1 "hello" + * npx tsx client.ts reset https://host/bots/ SHARED_SECRET ticket-1 + */ + +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; + +const HEADER_TIMESTAMP = 'X-LB-Timestamp'; +const HEADER_SIGNATURE = 'X-LB-Signature'; +const HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key'; +const REPLAY_WINDOW = 300; + +/** Compute the `sha256=` signature over `"{ts}." + body`. */ +export function sign(secret: string, body: Buffer | string, timestamp?: number): [string, string] { + const ts = String(timestamp ?? Math.floor(Date.now() / 1000)); + const buf = typeof body === 'string' ? Buffer.from(body) : body; + const mac = createHmac('sha256', secret).update(Buffer.concat([Buffer.from(`${ts}.`), buf])).digest('hex'); + return [ts, `sha256=${mac}`]; +} + +/** Verify an inbound signature (used by the callback receiver). */ +export function verify(secret: string, body: Buffer, timestamp?: string, signature?: string): boolean { + if (!timestamp || !signature) return false; + if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > REPLAY_WINDOW) return false; + const [, expected] = sign(secret, body, Number(timestamp)); + const a = Buffer.from(expected); + const b = Buffer.from(signature); + return a.length === b.length && timingSafeEqual(a, b); +} + +interface Segment { type: string; text?: string; url?: string; [k: string]: unknown } + +async function post(url: string, secret: string, payload: object, idempotency = true) { + const body = Buffer.from(JSON.stringify(payload)); + const [ts, sig] = sign(secret, body); + const headers: Record = { + 'Content-Type': 'application/json', + [HEADER_TIMESTAMP]: ts, + [HEADER_SIGNATURE]: sig, + }; + if (idempotency) headers[HEADER_IDEMPOTENCY] = randomUUID(); + const resp = await fetch(url, { method: 'POST', headers, body }); + const text = await resp.text(); + console.log(`-> ${resp.status} ${text}`); + return { status: resp.status, text }; +} + +/** Fire-and-collect: 202 now, reply later on your callback URL. */ +export function push(url: string, secret: string, session: string, text: string, sessionType = 'person') { + return post(url.replace(/\/$/, ''), secret, { + session_id: session, + session_type: sessionType, + message: [{ type: 'Plain', text }] as Segment[], + }); +} + +/** Blocking convenience: POST /sync, get the collapsed reply. */ +export function pushSync(url: string, secret: string, session: string, text: string, sessionType = 'person') { + return post(`${url.replace(/\/$/, '')}/sync`, secret, { + session_id: session, + session_type: sessionType, + message: [{ type: 'Plain', text }] as Segment[], + }, false); +} + +/** Reset a session's conversation. */ +export function reset(url: string, secret: string, session: string, sessionType = 'person') { + return post(`${url.replace(/\/$/, '')}/reset`, secret, { session_id: session, session_type: sessionType }, false); +} + +/** Run a callback receiver that verifies signatures and prints replies. */ +export function startReceiver(port: number, secret: string) { + const server = createServer((req, res) => { + if (req.method !== 'POST') { res.writeHead(405).end(); return; } + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks); + const ok = verify(secret, raw, req.headers[HEADER_TIMESTAMP.toLowerCase()] as string, + req.headers[HEADER_SIGNATURE.toLowerCase()] as string); + if (!ok) { + console.log('!! signature verification FAILED — rejecting'); + res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'bad signature' })); + return; + } + const data = JSON.parse(raw.toString()); + const parts = (data.message as Segment[]).filter((c) => c.type === 'Plain').map((c) => c.text).join(' '); + const marker = data.is_final ? 'FINAL' : 'part '; + console.log(`[${marker}] session=${data.session_id} seq=${data.sequence} reply_to=${data.reply_to}: ${parts}`); + res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true })); + }); + }); + server.listen(port, () => console.log(`callback receiver listening on http://0.0.0.0:${port}/ (Ctrl-C to stop)`)); +} + +// --- CLI --- +const [cmd, ...rest] = process.argv.slice(2); +if (cmd === 'serve') { + startReceiver(Number(rest[0] ?? 8900), rest[1] ?? 'SHARED_SECRET'); +} else if (cmd === 'push') { + push(rest[0], rest[1], rest[2], rest[3]); +} else if (cmd === 'sync') { + pushSync(rest[0], rest[1], rest[2], rest[3]); +} else if (cmd === 'reset') { + reset(rest[0], rest[1], rest[2]); +} else if (cmd) { + console.error(`unknown command: ${cmd}`); + process.exit(1); +} diff --git a/examples/http-bot/playground.py b/examples/http-bot/playground.py new file mode 100644 index 0000000..ea77d26 --- /dev/null +++ b/examples/http-bot/playground.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""LangBot HTTP Bot — interactive playground (public, browser-based). + +This is a REAL end-to-end demo against the RUNNING LangBot instance on this +host. It is NOT a mock and NOT an in-process import: every message you type in +the browser is signed and POSTed to the live `http_bot` bot at +http://127.0.0.1:5300/bots/, and the bot's replies come back to this +server's /callback endpoint over real HTTP, then stream to your browser via SSE. + +What it does on startup: + 1. Reads the LangBot API key + the http_bot bot from data/langbot.db. + 2. Configures the bot via the LangBot API (PUT /api/v1/platform/bots/): + sets inbound_secret + outbound_secret + callback_url to point back here. + (LangBot reloads the bot live — no server restart needed.) + 3. Serves a chat page on 0.0.0.0: so you can open it from the internet. + +Run: ./.venv/bin/python examples/http-bot/playground.py +Then open: http://:/ +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sqlite3 +import sys + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, os.path.join(REPO, 'src')) + +from aiohttp import web # noqa: E402 +import aiohttp # noqa: E402 + +from langbot.pkg.platform.sources import http_bot_signing as sg # noqa: E402 + +# ---- config ----------------------------------------------------------------- +LANGBOT_BASE = 'http://127.0.0.1:5300' +DB_PATH = os.path.join(REPO, 'data', 'langbot.db') +PUBLIC_IP = os.environ.get('PUBLIC_IP', '127.0.0.1') +PORT = int(os.environ.get('PLAYGROUND_PORT', '8920')) +SECRET = 'playground-shared-secret' + +# SSE subscribers: list of asyncio.Queue +subscribers: list[asyncio.Queue] = [] + + +def db_lookup() -> tuple[str, str]: + """Return (api_key, http_bot_uuid) from the LangBot DB.""" + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + api_key = db.execute('SELECT key FROM api_keys LIMIT 1').fetchone()['key'] + bot = db.execute("SELECT uuid FROM bots WHERE adapter='http_bot' LIMIT 1").fetchone() + if not bot: + raise SystemExit('No http_bot bot found. Create one in the WebUI first.') + return api_key, bot['uuid'] + + +async def configure_bot(api_key: str, bot_uuid: str, callback_url: str): + """Point the live bot at this playground via the LangBot API. + + update_bot() runs a raw SQL UPDATE with whatever keys we send, so we send a + MINIMAL payload: only adapter_config (built from scratch, not read back — + the GET masks secrets). LangBot reloads + reruns the bot live. + """ + cfg = { + 'inbound_secret': SECRET, + 'outbound_secret': SECRET, + 'callback_url': callback_url, + 'signature_required': True, + 'default_session_type': 'person', + 'callback_timeout': 15, + 'callback_max_retries': 3, + } + async with aiohttp.ClientSession() as s: + async with s.put( + f'{LANGBOT_BASE}/api/v1/platform/bots/{bot_uuid}', + headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}, + json={'adapter_config': cfg}, + ) as r: + txt = await r.text() + print(f'[configure] PUT adapter_config -> {r.status} {txt[:200]}') + return r.status < 400 + + +async def broadcast(event: dict): + for q in list(subscribers): + try: + q.put_nowait(event) + except Exception: + pass + + +# ---- HTTP handlers ---------------------------------------------------------- +async def index(request: web.Request): + return web.Response(text=PAGE, content_type='text/html') + + +async def send(request: web.Request): + """Browser -> here -> signed POST -> live LangBot bot.""" + body_in = await request.json() + session_id = body_in.get('session_id') or 'playground-1' + text = body_in.get('text', '') + bot_uuid = request.app['bot_uuid'] + + payload = { + 'session_id': session_id, + 'sender': {'id': 'browser-user', 'name': 'You'}, + 'message': [{'type': 'Plain', 'text': text}], + } + raw = json.dumps(payload, ensure_ascii=False).encode() + ts, sig = sg.sign(SECRET, raw) + url = f'{LANGBOT_BASE}/bots/{bot_uuid}' + + # echo what we send to the browser timeline + await broadcast( + {'dir': 'out', 'kind': 'request', 'session_id': session_id, 'text': text, 'url': url, 'sig': sig[:24] + '…'} + ) + + async with aiohttp.ClientSession() as s: + async with s.post( + url, + data=raw, + headers={ + 'Content-Type': 'application/json', + sg.HEADER_TIMESTAMP: ts, + sg.HEADER_SIGNATURE: sig, + }, + ) as r: + status = r.status + try: + jr = await r.json() + except Exception: + jr = {'raw': await r.text()} + await broadcast({'dir': 'in', 'kind': 'ack', 'status': status, 'data': jr}) + return web.json_response({'status': status, 'data': jr}) + + +async def callback(request: web.Request): + """Live LangBot bot -> here. Verify signature, stream to browser.""" + raw = await request.read() + ok, why = sg.verify(SECRET, raw, request.headers.get(sg.HEADER_TIMESTAMP), request.headers.get(sg.HEADER_SIGNATURE)) + data = json.loads(raw) + text = ' '.join(c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain') + await broadcast( + { + 'dir': 'in', + 'kind': 'reply', + 'session_id': data.get('session_id'), + 'sequence': data.get('sequence'), + 'is_final': data.get('is_final'), + 'sig_ok': ok, + 'sig_why': why, + 'text': text, + } + ) + return web.json_response({'ok': True}) + + +async def events(request: web.Request): + """SSE stream to the browser.""" + resp = web.StreamResponse( + headers={ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + } + ) + await resp.prepare(request) + q: asyncio.Queue = asyncio.Queue() + subscribers.append(q) + try: + await resp.write(b': connected\n\n') + while True: + try: + ev = await asyncio.wait_for(q.get(), timeout=15) + await resp.write(f'data: {json.dumps(ev, ensure_ascii=False)}\n\n'.encode()) + except asyncio.TimeoutError: + await resp.write(b': ping\n\n') + except (asyncio.CancelledError, ConnectionResetError): + pass + finally: + if q in subscribers: + subscribers.remove(q) + return resp + + +PAGE = r""" + + +LangBot HTTP Bot · 调试台 + + +
+ + HTTP Bot 调试台examples/http-bot + 连接中… +
+
+ +
+

对话 · 真实发往运行中的 http_bot

+
+
+ + +
+
+ +
+

调试信息

+
入站地址
/bots/<uuid>
+
签名 HMAC-SHA256 · X-LB-Signature
+
+ 会话 + + +
+
+
+
+ +""" + + +async def main(): + api_key, bot_uuid = db_lookup() + callback_url = f'http://{PUBLIC_IP}:{PORT}/callback' + print(f'[init] http_bot uuid = {bot_uuid}') + print(f'[init] callback_url = {callback_url}') + ok = await configure_bot(api_key, bot_uuid, callback_url) + if not ok: + print('[warn] bot config update failed; check the API key / payload shape') + + app = web.Application() + app['bot_uuid'] = bot_uuid + app.router.add_get('/', index) + app.router.add_post('/send', send) + app.router.add_post('/callback', callback) + app.router.add_get('/events', events) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '0.0.0.0', PORT) + await site.start() + print(f'\n ▶ 打开: http://{PUBLIC_IP}:{PORT}/\n') + while True: + await asyncio.sleep(3600) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/web-page-bot/README.md b/examples/web-page-bot/README.md new file mode 100644 index 0000000..e31f41c --- /dev/null +++ b/examples/web-page-bot/README.md @@ -0,0 +1,48 @@ +# Page Bot Adapter — Embed Demo + +> English | [中文](./README.zh.md) + +A single self-contained HTML page that demos the LangBot **Page Bot** +(`web_page_bot`) embeddable chat widget — the one you drop onto any website with +a single ``. +- `widget.js` is served by LangBot pre-configured for that bot UUID — title, + bubble icon, language and optional Cloudflare Turnstile protection all come + from the bot's config, no page changes needed. +- Messages travel over a WebSocket to the bot's bound pipeline; replies stream + back into the bubble. + +> The widget loads `widget.js` from your LangBot instance, so the **base URL +> must be reachable from the browser** you open this page in. If LangBot runs on +> a server, use its public address instead of `localhost`. diff --git a/examples/web-page-bot/README.zh.md b/examples/web-page-bot/README.zh.md new file mode 100644 index 0000000..9006464 --- /dev/null +++ b/examples/web-page-bot/README.zh.md @@ -0,0 +1,44 @@ +# 页面机器人适配器 —— 嵌入演示 + +> [English](./README.md) | 中文 + +一个自包含的单文件 HTML 页面,用于演示 LangBot **页面机器人** +(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 ``。 +- `widget.js` 由 LangBot 针对该机器人 UUID 预配置后下发 —— 标题、气泡图标、语言 + 以及可选的 Cloudflare Turnstile 防护,全部来自机器人配置,无需改动页面。 +- 消息通过 WebSocket 发往机器人绑定的流水线,回复流式回到气泡中。 + +> 组件会从你的 LangBot 实例加载 `widget.js`,因此 **base URL 必须能从你打开本页 +> 的浏览器访问到**。如果 LangBot 部署在服务器上,请用它的公网地址而非 +> `localhost`。 diff --git a/examples/web-page-bot/index.html b/examples/web-page-bot/index.html new file mode 100644 index 0000000..ef88906 --- /dev/null +++ b/examples/web-page-bot/index.html @@ -0,0 +1,205 @@ + + + + + +LangBot Page Bot · Embed Demo + + + +
+ + Page Bot · Embed Demo + examples/web-page-bot +
+ +
+
+

Try the LangBot Page Bot widget

+

Point this page at a running LangBot instance and a Page Bot you created,

+

then load the live embed widget below to chat with it — exactly as your site visitors would.

+
+ +
+

1 Connect your Page Bot

+
+ + +
The address where your LangBot instance is reachable from this browser. No trailing slash.
+
+
+ + +
Create a bot with the Page Bot adapter in the WebUI, then copy its UUID from the embed code.
+
+
+ + +
+
+ + + Not loaded. +
+
+ +
+

2 The embed snippet

+

This is exactly what you paste into your own site (before </body>). It updates as you edit the fields above.

+
+ +
<script data-title="LangBot" src="http://localhost:5300/api/v1/embed/<bot-uuid>/widget.js"></script>
+
+
+ +
+

3 How it works

+
    +
  • The <script> tag pulls widget.js from your LangBot instance, pre-configured for that bot UUID.
  • +
  • A floating chat bubble appears in the bottom-right corner of the page.
  • +
  • Messages travel over a WebSocket to the bot's bound pipeline; replies stream back into the bubble.
  • +
  • Title, bubble icon, language and optional Cloudflare Turnstile protection are all set in the bot's config — no page changes needed.
  • +
+
+
+ + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..9e1f5c3 --- /dev/null +++ b/main.py @@ -0,0 +1,3 @@ +import langbot.__main__ + +langbot.__main__.main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c528bce --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,227 @@ +[project] +name = "langbot" +version = "4.10.5" +description = "Production-grade platform for building agentic IM bots" +readme = "README.md" +license-files = ["LICENSE"] +requires-python = ">=3.11,<4.0" +dependencies = [ + "aiocqhttp>=1.4.4", + "aiofiles>=24.1.0", + "aiohttp>=3.14.1", + "aioshutil>=1.5", + "aiosqlite>=0.21.0", + "anthropic>=0.51.0", + "argon2-cffi>=23.1.0", + "async-lru>=2.0.5", + "certifi>=2025.4.26", + "colorlog~=6.6.0", + "cryptography>=48.0.1", + "dashscope>=1.25.10", + "dingtalk-stream>=0.24.0", + "discord-py>=2.5.2", + "pynacl>=1.5.0", # Required for Discord voice support + "gewechat-client>=0.1.5", + "lark-oapi>=1.5.5", + "mcp>=1.25.0", + "nakuru-project-idk>=0.0.2.1", + "ollama>=0.4.8", + "openai>1.0.0", + "pillow>=12.2.0", + "psutil>=7.0.0", + "pycryptodome>=3.22.0", + "pydantic>2.0", + "pyjwt>=2.12.0", + "python-telegram-bot>=22.0", + "pyyaml>=6.0.2", + "qq-botpy-rc>=1.2.1.6", + "qrcode>=7.4", + "quart>=0.20.0", + "quart-cors>=0.8.0", + "requests>=2.33.0", + "slack-sdk>=3.35.0", + "alembic>=1.15.0", + "sqlalchemy[asyncio]>=2.0.40", + "sqlmodel>=0.0.24", + "telegramify-markdown>=0.5.1", + "tiktoken>=0.9.0", + "urllib3>=2.7.0", + "websockets>=15.0.1", + "python-socks>=2.7.1", # dingtalk missing dependency + "pip>=26.1", + "ruff>=0.11.9", + "pre-commit>=4.2.0", + "uv>=0.11.15", + "mypy>=1.16.0", + "PyPDF2>=3.0.1", + "python-docx>=1.1.0", + "pandas>=2.2.2", + "chardet>=5.2.0", + "markdown>=3.6", + "beautifulsoup4>=4.12.3", + "ebooklib>=0.18", + "html2text>=2024.2.26", + "langchain>=1.3.9", + "langchain-core>=1.3.3", + "langsmith>=0.8.18", + "python-multipart>=0.0.27", + "Mako>=1.3.12", + "langchain-text-splitters>=1.1.2", + "chromadb>=1.0.0,<2.0.0", + "qdrant-client (>=1.15.1,<2.0.0)", + "pyseekdb==1.1.0.post3", + "langbot-plugin==0.4.13", + "asyncpg>=0.30.0", + "line-bot-sdk>=3.19.0", + "matrix-nio>=0.25.2", + "tboxsdk>=0.0.10", + "boto3>=1.35.0", + "pymilvus>=2.6.4", + "pgvector>=0.4.1", + "botocore>=1.42.39", + "litellm>=1.0.0", + "valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published +] +keywords = [ + "bot", + "agent", + "telegram", + "plugins", + "openai", + "instant-messaging", + "wechat", + "qq", + "dify", + "llm", + "chatgpt", + "deepseek", + "onebot", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: AsyncIO", + "Framework :: Robot Framework", + "Framework :: Robot Framework :: Library", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Communications :: Chat", +] + +[project.urls] +Homepage = "https://langbot.app" +Documentation = "https://docs.langbot.app" +Repository = "https://github.com/langbot-app/LangBot" + +[project.scripts] +langbot = "langbot.__main__:main" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] } + +[dependency-groups] +dev = [ + "moto>=5.2.1", + "pre-commit>=4.2.0", + "pytest>=9.0.3", + "pytest-asyncio>=1.0.0", + "pytest-cov>=7.0.0", + "ruff>=0.11.9", +] + +[tool.ruff] +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +line-length = 120 +indent-width = 4 + +# Assume Python 3.12 +target-version = "py312" + +[tool.ruff.lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +select = ["E4", "E7", "E9", "F"] +ignore = [ + "E712", # Comparison to true should be 'if cond is true:' or 'if cond:' (E712) + "F402", # Import `loader` from line 8 shadowed by loop variable + "F403", # * used, unable to detect undefined names + "F405", # may be undefined, or defined from star imports + "E741", # Ambiguous variable name: `l` + "E722", # bare-except + "E721", # type-comparison + "F821", # undefined-all + "FURB113", # repeated-append + "FURB152", # math-constant + "UP007", # non-pep604-annotation + "UP032", # f-string + "UP045", # non-pep604-annotation-optional + "B005", # strip-with-multi-characters + "B006", # mutable-argument-default + "B007", # unused-loop-control-variable + "B026", # star-arg-unpacking-after-keyword-arg + "B903", # class-as-data-structure + "B904", # raise-without-from-inside-except + "B905", # zip-without-explicit-strict + "N806", # non-lowercase-variable-in-function + "N815", # mixed-case-variable-in-class-scope + "PT011", # pytest-raises-too-broad + "SIM102", # collapsible-if + "SIM103", # needless-bool + "SIM105", # suppressible-exception + "SIM107", # return-in-try-except-finally + "SIM108", # if-else-block-instead-of-if-exp + "SIM113", # enumerate-for-loop + "SIM117", # multiple-with-statements + "SIM210", # if-expr-with-true-false +] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[tool.ruff.format] +# Like Black, use double quotes for strings. +quote-style = "single" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a430a96 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,44 @@ +[pytest] +# Test discovery patterns +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Python path for imports +pythonpath = . tests + +# Test paths +testpaths = tests + +# Asyncio configuration +asyncio_mode = auto + +# Output options +addopts = + -v + --strict-markers + --tb=short + --disable-warnings + +# Markers +markers = + asyncio: mark test as async + unit: mark test as unit test + integration: mark test as integration test + smoke: mark test as smoke test + slow: mark test as slow running + e2e: mark test as end-to-end test (requires real LangBot process) + +# Coverage options (when using pytest-cov) +[coverage:run] +source = langbot +omit = + */tests/* + */test_*.py + */__pycache__/* + */site-packages/* + +[coverage:report] +precision = 2 +show_missing = True +skip_covered = False diff --git a/res/announcement.json b/res/announcement.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/res/announcement.json @@ -0,0 +1 @@ +[] diff --git a/res/announcement_saved.json b/res/announcement_saved.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/res/announcement_saved.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/res/dashboard-overview.png b/res/dashboard-overview.png new file mode 100644 index 0000000..459a3ec Binary files /dev/null and b/res/dashboard-overview.png differ diff --git a/res/instance_id.json b/res/instance_id.json new file mode 100644 index 0000000..bfd6df4 --- /dev/null +++ b/res/instance_id.json @@ -0,0 +1 @@ +{"host_id": "host_9b4a220d-3bb6-42fc-aec3-41188ce0a41c", "instance_id": "instance_61d8f262-b98a-4165-8e77-85fb6262529e", "instance_create_ts": 1736824678} \ No newline at end of file diff --git a/res/logo-blue.png b/res/logo-blue.png new file mode 100644 index 0000000..567a2e4 Binary files /dev/null and b/res/logo-blue.png differ diff --git a/res/logo.png b/res/logo.png new file mode 100644 index 0000000..b2caca7 Binary files /dev/null and b/res/logo.png differ diff --git a/res/scripts/publish_announcement.py b/res/scripts/publish_announcement.py new file mode 100644 index 0000000..7d2e7d4 --- /dev/null +++ b/res/scripts/publish_announcement.py @@ -0,0 +1,32 @@ +# 输出工作路径 +import os +import time +import json + +print('工作路径: ' + os.getcwd()) +announcement = input('请输入公告内容: ') + +# 读取现有的公告文件 res/announcement.json +with open('res/announcement.json', 'r', encoding='utf-8') as f: + announcement_json = json.load(f) + +# 将公告内容写入公告文件 + +# 当前自然时间 +now = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime()) + +# 获取最后一个公告的id +last_id = announcement_json[-1]['id'] if len(announcement_json) > 0 else -1 + +announcement = { + 'id': last_id + 1, + 'time': now, + 'timestamp': int(time.time()), + 'content': announcement, +} + +announcement_json.append(announcement) + +# 将公告写入公告文件 +with open('res/announcement.json', 'w', encoding='utf-8') as f: + json.dump(announcement_json, f, indent=4, ensure_ascii=False) diff --git a/res/social.png b/res/social.png new file mode 100644 index 0000000..7e35117 Binary files /dev/null and b/res/social.png differ diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..8e68d02 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Script to run all unit tests +# This script helps avoid circular import issues by setting up the environment properly + +set -e + +echo "Setting up test environment..." + +# Activate virtual environment if it exists +if [ -d ".venv" ]; then + source .venv/bin/activate +fi + +# Check if pytest is installed +if ! command -v pytest &> /dev/null; then + echo "Installing test dependencies..." + pip install pytest pytest-asyncio pytest-cov +fi + +echo "Running all unit tests..." + +# Run tests with coverage +pytest tests/unit_tests/ -v --tb=short \ + --cov=langbot \ + --cov-report=xml \ + "$@" + +echo "" +echo "Test run complete!" +echo "Coverage report saved to coverage.xml" diff --git a/scripts/build_dingtalk_card_template.py b/scripts/build_dingtalk_card_template.py new file mode 100644 index 0000000..aae6441 --- /dev/null +++ b/scripts/build_dingtalk_card_template.py @@ -0,0 +1,1111 @@ +"""Generate the DingTalk human-input card template JSON. + +The output is wrapped in the {editorData, widgetInfo, type, mode} envelope +the DingTalk card builder expects on import. editorData is itself a JSON +string (NOT a nested object), matching real exports from the builder. + +Run from the repo root: python scripts/build_dingtalk_card_template.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +OUTPUT = Path('src/langbot/templates/dingtalk_human_input_card.json') + + +def markdown_block(node_id, variable='content'): + """A MarkdownBlock whose content is bound to a global variable. + + Critical: `content.varType: "markdown"` must be set, otherwise DingTalk + silently fails to render the bound variable (the card body stays blank + even though the variable is supplied via cardParamMap). The working + reference template in I:\\下载\\dingtalk_1782055283543.json confirms + this — its MarkdownBlock has the same varType marker. + + isStreaming is left `false` because the adapter writes the variable via + `update_card_data` (the full-card PUT endpoint), not the streaming + `card/streaming` endpoint. Setting `isStreaming: true` here conflicts + with that path and can suppress the rendered body. + """ + return { + 'componentName': 'MarkdownBlock', + 'id': node_id, + 'props': { + 'mdVer': 0, + 'icon': {'type': 'icon', 'icon': '', 'iconType': 'emoji'}, + 'content': { + 'variable': variable, + 'variableType': 'global', + 'type': 'variableValue', + 'varType': 'markdown', + }, + 'visible': { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'fixed', + 'condition': {'op': 'and', 'conditions': []}, + }, + 'isStreaming': False, + 'enableLinkStatPoint': False, + 'linkStatPoint': { + 'type': 'dynamicString', + 'content': 'Page_InteractiveCard__Click_markdownOpenlink', + 'i18n': False, + }, + 'linkStatPointParams': [], + 'marginTop': 6, + 'marginBottom': 6, + 'marginLeft': 12, + 'marginRight': 12, + }, + 'title': 'AI 流式富文本', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + } + + +def _dynamic_string_var(variable): + return {'type': 'dynamicString', 'content': '', 'i18n': False, 'variable': variable, 'variableType': 'global'} + + +def _dynamic_visible_var(variable): + return { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'variable', + 'variable': variable, + 'variableType': 'global', + 'condition': {'op': 'and', 'conditions': []}, + } + + +SELECT_OPTION_LOCALES = ( + 'zh_CN', + 'zh_TW', + 'en_US', + 'ja_JP', + 'vi_VN', + 'th_TH', + 'id_ID', + 'ne_NP', + 'ms_MY', + 'ko_KR', + 'ru_RU', + 'es_EA', + 'tr_TR', + 'fr_FR', + 'pt_BR', +) + + +def _empty_select_option(): + return {'value': '', 'text': {locale: '' for locale in SELECT_OPTION_LOCALES}} + + +def _select_options_variable(): + return { + 'name': 'index_o', + 'private': False, + 'type': 'selectOptions', + 'id': 'index_o', + 'description': 'Select options', + 'editorVarType': 'variables', + 'disabled': False, + 'schema': [ + { + 'id': 'index_o.value', + 'type': 'string', + 'name': 'value', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '', + }, + { + 'id': 'index_o.text', + 'type': 'object', + 'name': 'text', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '', + 'schema': [ + { + 'id': f'index_o.{locale}', + 'type': 'string', + 'name': locale, + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '', + } + for locale in SELECT_OPTION_LOCALES + ], + }, + ], + } + + +def input_block(node_id): + return { + 'componentName': 'Input', + 'id': node_id, + 'props': { + 'placeholder': _dynamic_string_var('input_placeholder'), + 'currentValue': _dynamic_string_var('input_value'), + 'message': _dynamic_string_var('input_placeholder'), + 'title': _dynamic_string_var('input_title'), + 'id': {'type': 'dynamicString', 'content': 'input', 'i18n': False}, + 'params': [ + { + 'type': 'builtIn', + 'variable': '', + 'value': '', + 'name': 'input', + 'variableType': 'global', + 'id': '__built_in_inputResult__', + } + ], + 'visible': _dynamic_visible_var('input_visible'), + 'status': { + 'type': 'dynamicSelect', + 'valueType': 'fixed', + 'value': 'normal', + 'variable': '', + 'variableType': 'global', + }, + 'actionType': 'request', + 'localVarAction': {'type': 'variableValue', 'variableType': 'global', 'variable': ''}, + 'keyOfDynamicObject': {'type': 'dynamicString', 'content': '', 'i18n': False}, + 'inlineMode': False, + 'textArea': True, + 'minRows': { + 'type': 'dynamicNumber', + 'valueType': 'fixed', + 'value': 2, + 'variable': '', + 'variableType': 'global', + }, + 'maxRows': { + 'type': 'dynamicNumber', + 'valueType': 'fixed', + 'value': 6, + 'variable': '', + 'variableType': 'global', + }, + 'marginLeft': 12, + 'marginRight': 12, + 'marginTop': 6, + 'marginBottom': 6, + 'margin': 12, + 'innerOffset': 0, + }, + 'title': 'Text input', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + } + + +def select_block(node_id): + return { + 'componentName': 'SelectBlock', + 'id': node_id, + 'props': { + 'id': {'type': 'dynamicString', 'content': 'select', 'i18n': False}, + 'placeholder': _dynamic_string_var('select_placeholder'), + 'currentIndex': { + 'type': 'dynamicNumber', + 'valueType': 'variable', + 'value': -1, + 'variable': 'select_index', + 'variableType': 'global', + }, + 'options': { + 'type': 'dynamicSelectOptions', + 'valueType': 'variable', + 'value': [], + 'variable': 'index_o', + 'variableType': 'global', + }, + 'optionLabelMaxLines': 3, + 'params': [ + { + 'type': 'builtIn', + 'variable': '', + 'value': '{"index": ${index}, "value": "${value}"}', + 'name': 'select', + 'variableType': 'global', + 'id': '__built_in_selectResult__', + } + ], + 'actionType': 'request', + 'localVarAction': {'type': 'variableValue', 'variableType': 'global', 'variable': ''}, + 'keyOfDynamicObject': {'type': 'dynamicString', 'content': '', 'i18n': False}, + 'status': { + 'type': 'dynamicSelect', + 'valueType': 'fixed', + 'value': 'normal', + 'variable': '', + 'variableType': 'global', + }, + 'visible': _dynamic_visible_var('select_visible'), + 'marginLeft': 12, + 'marginRight': 12, + 'marginTop': 6, + 'marginBottom': 6, + 'pullOptionsWhileOpen': False, + 'pullOptionsRequestParams': [], + 'margin': 12, + 'innerOffset': 0, + }, + 'title': 'Select', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + } + + +def text_block( + node_id, + text, + *, + bold=False, + gravity='left', + font_size=14, + line_height=22, + max_lines=20, + ml=12, + mr=12, + mt=4, + mb=4, + color_token='common_level1_base_color', + style_token='common_body_text_style', +): + return { + 'componentName': 'BaseText', + 'id': node_id, + 'props': { + 'text': {'i18n': False, 'type': 'dynamicString', 'content': text}, + 'hoverText': {'type': 'dynamicString', 'content': '', 'i18n': False}, + 'iconType': 'iconCode', + 'iconFont': {'type': 'icon', 'icon': '', 'iconType': 'ddIcon'}, + 'icon': { + 'type': 'dynamicLink', + 'value': '', + 'valueType': 'fixed', + 'variable': '', + 'variableType': 'global', + }, + 'darkIcon': { + 'type': 'dynamicLink', + 'value': '', + 'valueType': 'fixed', + 'variable': '', + 'variableType': 'global', + }, + 'autoWidth': False, + 'maxWidth': { + 'type': 'dynamicNumber', + 'valueType': 'fixed', + 'value': 0, + 'variable': '', + 'variableType': 'global', + }, + 'fixedWidth': { + 'type': 'dynamicNumber', + 'valueType': 'fixed', + 'value': 0, + 'variable': '', + 'variableType': 'global', + }, + 'marginLeft': ml, + 'marginRight': mr, + 'marginTop': mt, + 'marginBottom': mb, + 'fontColorType': 'Standard', + 'enableHighlight': False, + 'maxLine': { + 'type': 'dynamicNumber', + 'valueType': 'fixed', + 'value': max_lines, + 'variable': '', + 'variableType': 'global', + }, + 'color': { + 'type': 'dynamicColor', + 'valueType': 'fixed', + 'value': color_token, + 'variable': '', + 'variableType': 'global', + }, + 'customLightColor': { + 'type': 'dynamicColor', + 'valueType': 'fixed', + 'value': '#35404b', + 'variable': '', + 'variableType': 'global', + }, + 'customDarkColor': { + 'type': 'dynamicColor', + 'valueType': 'fixed', + 'value': '#f6f6f6', + 'variable': '', + 'variableType': 'global', + }, + 'gravity': gravity, + 'fontSizeType': 'Standard', + 'styleType': 'custom', + 'styleToken': style_token, + 'size': 'middle', + 'customFontSize': font_size, + 'customFontLineHeight': line_height, + 'bold': bold, + 'italic': False, + 'strikeThrough': False, + 'lineHeight': 'normal', + 'visible': { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'fixed', + 'condition': {'op': 'and', 'conditions': []}, + }, + 'autoMaxWidth': False, + 'innerOffset': 0, + 'enableIcon': False, + 'widthMode': 'match_parent', + 'margin': -2, + }, + 'title': '基础文本', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + } + + +def button_group(node_id): + return { + 'componentName': 'ButtonGroup', + 'id': node_id, + 'props': { + 'dynamicButtons': {'type': 'variableValue', 'variableType': 'global', 'variable': 'btns'}, + 'marginLeft': 12, + 'marginRight': 12, + 'marginTop': 6, + 'marginBottom': 12, + 'visible': { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'fixed', + 'condition': {'op': 'and', 'conditions': []}, + }, + 'responsiveLayoutWidth': 350, + 'buttonsSource': 'variable', + 'fixedButtonIds': [], + 'fixedButtons': [], + 'enableResponsiveLayout': False, + 'matchContent': False, + 'buttonSpacing': 8, + 'margin': -2, + 'innerOffset': 0, + }, + 'title': '按钮组', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + } + + +def avatar(node_id, *, name='LangBot', image_variable='bot_avatar'): + """Avatar component in `userInfo` mode — renders the bot's avatar + image and nickname as a header row above the response content. + Mirrors the layout from `I:\\下载\\dingtalk_1782120006374.json` where + Avatar sits at the top of the done-state AICardContent. + + `imageUrl` is bound to a global variable (default `bot_avatar`) so + the adapter can populate it at runtime with a DingTalk media id + (``@xxx``) obtained from the /media/upload endpoint. DingTalk's + Avatar.imageUrl resolver rejects external URLs — it only accepts + DingTalk-hosted media ids, so this binding is the only path to + a custom avatar. + """ + return { + 'componentName': 'Avatar', + 'id': node_id, + 'props': { + 'imageUrl': { + 'value': '', + 'valueType': 'variable', + 'type': 'dynamicImage', + 'variable': image_variable, + 'variableType': 'global', + }, + 'name': {'i18n': False, 'type': 'dynamicString', 'content': name}, + 'sizeType': 'Standard', + 'size': 'extraSmall', + 'customSize': 48, + 'marginLeft': 12, + 'marginRight': 12, + 'marginTop': 6, + 'marginBottom': 6, + 'visible': { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'fixed', + 'condition': {'op': 'and', 'conditions': []}, + }, + 'mode': 'userInfo', + 'margin': -2, + 'innerOffset': 0, + }, + 'title': '头像', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + } + + +def build_editor_data(): + component_names = [ + 'AIPending', + 'AICardStatusContainer', + 'BaseText', + 'AICardContent', + 'AICardContainer', + 'ButtonGroup', + 'MarkdownBlock', + 'Avatar', + 'Input', + 'SelectBlock', + ] + components_map = [ + { + 'package': '@ali/dxComponent', + 'version': '1.0.0', + 'exportName': n, + 'main': './src/index.tsx', + 'destructuring': False, + 'subName': '', + 'componentName': n, + } + for n in component_names + ] + + pending_state = { + 'componentName': 'AICardStatusContainer', + 'id': 'node_status_pending', + 'props': { + 'status': 1, + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'enableExtend': False, + 'autoFoldConfig': { + 'needFold': True, + 'heightLimit': 480, + 'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey', + }, + 'innerOffset': 0, + 'enableCollapse': False, + 'margin': -2, + }, + 'title': '处理中状态', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + 'children': [ + { + 'componentName': 'AIPending', + 'id': 'node_pending_inner', + 'props': { + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'pendingTip': {'type': 'dynamicString', 'content': '处理中...', 'i18n': False}, + 'style': 'embed', + 'hideIcon': False, + }, + 'hidden': False, + 'title': '', + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + } + ], + } + + done_state = { + 'componentName': 'AICardStatusContainer', + 'id': 'node_status_done', + 'props': { + 'status': 3, + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'enableExtend': False, + 'autoFoldConfig': { + 'needFold': True, + 'heightLimit': 480, + 'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey', + }, + 'innerOffset': 0, + 'enableCollapse': False, + 'margin': -2, + }, + 'title': '完成状态', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + 'children': [ + { + 'componentName': 'AICardContent', + 'id': 'node_done_content', + 'props': { + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'visible': { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'fixed', + 'condition': {'op': 'and', 'conditions': []}, + }, + 'innerOffset': 0, + 'disabledWhileForward': False, + 'statPoint': {'type': 'dynamicString', 'content': '', 'i18n': False}, + 'statPointParams': [ + {'type': 'fixed', 'variable': '', 'value': '', 'name': '', 'variableType': 'global', 'id': '1'} + ], + 'margin': -2, + 'transformToEventChain': False, + 'enableStatPoint': False, + }, + 'hidden': False, + 'title': '', + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + 'children': [ + avatar('node_avatar', name='LangBot'), + markdown_block('node_text_content', variable='content'), + input_block('node_input'), + select_block('node_select'), + button_group('node_btn_group'), + ], + } + ], + } + + failed_state = { + 'componentName': 'AICardStatusContainer', + 'id': 'node_status_failed', + 'props': { + 'status': 5, + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'enableExtend': False, + 'autoFoldConfig': { + 'needFold': True, + 'heightLimit': 480, + 'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey', + }, + 'innerOffset': 0, + 'enableCollapse': False, + 'margin': -2, + }, + 'title': '失败状态', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + 'children': [ + { + 'componentName': 'AICardContent', + 'id': 'node_failed_content', + 'props': { + 'visible': { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'fixed', + 'condition': {'op': 'and', 'conditions': []}, + }, + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'innerOffset': 0, + 'disabledWhileForward': False, + 'statPoint': {'type': 'dynamicString', 'content': '', 'i18n': False}, + 'statPointParams': [ + {'type': 'fixed', 'variable': '', 'value': '', 'name': '', 'variableType': 'global', 'id': '1'} + ], + 'margin': -2, + 'transformToEventChain': False, + 'enableStatPoint': False, + }, + 'hidden': False, + 'title': '', + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + 'children': [ + text_block( + 'node_failed_text', + '操作失败,请稍后重试。', + gravity='center', + mt=10, + mb=10, + ml=10, + mr=10, + max_lines=2, + font_size=15, + ) + ], + } + ], + } + + # Empty containers for flowStatus=2 (writing) and flowStatus=4 (doing). + # AICardContainer expects placeholders to exist for every enabled state; + # without them, the renderer can refuse to advance to flowStatus=3 (done) + # and the card body stays empty. They render nothing visible because + # they have no content children, but their presence satisfies the + # state-machine validation. + def _empty_status_container(node_id, status): + return { + 'componentName': 'AICardStatusContainer', + 'id': node_id, + 'props': { + 'status': status, + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'enableExtend': False, + 'autoFoldConfig': { + 'needFold': True, + 'heightLimit': 480, + 'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey', + }, + 'innerOffset': 0, + 'enableCollapse': False, + 'margin': -2, + }, + 'title': f'状态{status}占位', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + 'children': [ + { + 'componentName': 'AICardContent', + 'id': f'{node_id}_content', + 'props': { + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'visible': { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'fixed', + 'condition': {'op': 'and', 'conditions': []}, + }, + 'innerOffset': 0, + 'disabledWhileForward': False, + 'statPoint': {'type': 'dynamicString', 'content': '', 'i18n': False}, + 'statPointParams': [ + { + 'type': 'fixed', + 'variable': '', + 'value': '', + 'name': '', + 'variableType': 'global', + 'id': '1', + } + ], + 'margin': -2, + 'transformToEventChain': False, + 'enableStatPoint': False, + }, + 'hidden': False, + 'title': '', + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + 'children': [], + } + ], + } + + writing_state = _empty_status_container('node_status_writing', 2) + doing_state = _empty_status_container('node_status_doing', 4) + + root = { + 'componentName': 'AICardContainer', + 'id': 'node_root', + 'props': { + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'enablePending': True, + # writing/doing must be enabled so AICardContainer recognises + # flowStatus transitions through 2/4 — without this, the + # working reference template (I:\\下载\\dingtalk_1782055283543.json) + # never reaches the done state and the body stays empty. + 'enableWriting': True, + 'enableDoing': True, + 'enableFailed': True, + 'summaryContent': {'type': 'variableValue', 'variableType': 'global', 'variable': ''}, + 'enableTitle': False, + 'flowStatusVar': {'type': 'variableValue', 'variableType': 'global', 'variable': 'flowStatus'}, + 'operationPenalType': 'custom', + 'enableFlowAbort': True, + 'innerOffset': 0, + 'enableGradientBorder': True, + 'cardSizeMode': 'adaptive', + 'cardSizeHeightMode': 'adaptive', + 'cardSizeWidthMode': 'adaptive', + 'cardSizeHeight': { + 'type': 'dynamicNumber', + 'valueType': 'fixed', + 'value': 226, + 'variable': '', + 'variableType': 'global', + }, + 'hasBackground': False, + 'backgroundType': 'Standard', + 'standardBackgroundColor': 'gray', + 'backgroundColor': '#F6F6F6', + 'darkModeBackgroundColor': '#3C3C3C', + 'enableEngineUpgrade': False, + 'enableExposeStatPoint': False, + 'enableDebugTool': False, + }, + 'hidden': False, + 'title': '', + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + 'children': [pending_state, writing_state, doing_state, done_state, failed_state], + } + + btns_var = { + 'name': 'btns', + 'private': False, + 'type': 'buttonGroup', + 'id': 'btns', + 'description': '动态按钮列表(Dify actions)', + 'editorVarType': 'variables', + 'disabled': False, + 'schema': [ + { + 'id': 'btns.text', + 'type': 'string', + 'name': 'text', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '按钮文案', + }, + { + 'id': 'btns.color', + 'type': 'string', + 'name': 'color', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '按钮颜色', + }, + { + 'id': 'btns.status', + 'type': 'string', + 'name': 'status', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '按钮状态', + }, + { + 'id': 'btns.event', + 'type': 'dynamicEvent', + 'name': 'event', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '按钮点击事件', + 'schema': [ + { + 'id': 'btns.type', + 'type': 'string', + 'name': 'type', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '事件类型:openLink / sendCardRequest', + }, + { + 'id': 'btns.params', + 'type': 'object', + 'name': 'params', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '事件参数', + 'schema': [ + { + 'id': 'btns.url', + 'type': 'string', + 'name': 'url', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '点击跳转链接(type=openLink)', + }, + { + 'id': 'btns.actionId', + 'type': 'string', + 'name': 'actionId', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '回传请求 id(type=sendCardRequest)', + }, + { + 'id': 'btns.params', + 'type': 'object', + 'name': 'params', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '回传请求参数(type=sendCardRequest)', + }, + ], + }, + ], + }, + ], + } + + return { + 'schemaVersion': '3.0.0', + 'schema': { + # Match the working reference template — leaving config null lets + # DingTalk pick defaults. Explicit `streaming_mode: true` would + # make the renderer wait for chunks on the streaming endpoint + # (PUT /v1.0/card/streaming), which our adapter does NOT use — + # it pushes content via update_card_data, so streaming_mode=true + # leaves the body empty. + 'config': None, + 'componentsMap': components_map, + 'componentsTree': [root], + 'i18n': {}, + 'version': '1.0.0', + }, + 'mockData': { + 'cardData': { + 'flowStatus': 3, + 'content': '请审核以下报销申请:\n\n- 申请人:张三\n- 金额:¥1,200\n- 类别:差旅', + 'input_visible': '', + 'input_title': '', + 'input_placeholder': '', + 'input_value': '', + 'select_visible': '', + 'select_placeholder': '', + 'index_o': [_empty_select_option()], + 'select_options': [], + 'select_index': -1, + 'btns': [ + { + 'text': '通过', + 'color': 'blue', + 'status': 'normal', + 'event': { + 'type': 'sendCardRequest', + 'params': {'actionId': 'approve', 'params': {'action_id': 'approve'}}, + }, + }, + { + 'text': '驳回', + 'color': 'gray', + 'status': 'normal', + 'event': { + 'type': 'sendCardRequest', + 'params': {'actionId': 'reject', 'params': {'action_id': 'reject'}}, + }, + }, + { + 'text': '补充资料', + 'color': 'gray', + 'status': 'normal', + 'event': { + 'type': 'sendCardRequest', + 'params': {'actionId': 'more_info', 'params': {'action_id': 'more_info'}}, + }, + }, + ], + }, + 'cardPrivateData': {}, + 'localData': {'flowStatus': '', '_cardFoldStatusLocalDataKey': ''}, + 'richTextData': {}, + }, + 'renderContext': {'regenerateEnabled': '1', 'regenerateIndex': '2', 'regenerateTotal': '5'}, + 'editVersion': 0, + 'customWidgetInfo': '', + 'useCustomWidgetInfo': False, + 'variableList': [ + { + 'id': 'content', + 'type': 'markdown', + 'name': 'content', + 'description': '人工输入提示词(Dify form_content 含可选 node_title 前缀)', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'flowStatus', + 'type': 'string', + 'name': 'flowStatus', + 'description': 'AI卡片状态:pending(1)、writing(2)、done(3)、failed(5)', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'visible': False, + }, + { + 'id': 'bot_avatar', + 'type': 'string', + 'name': 'bot_avatar', + 'description': '机器人头像 DingTalk 媒体 ID(@xxx 格式,启动时由 /media/upload 拿到)', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'input_visible', + 'type': 'string', + 'name': 'input_visible', + 'description': 'Whether to show the text input component', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'input_title', + 'type': 'string', + 'name': 'input_title', + 'description': 'Text input title', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'input_placeholder', + 'type': 'string', + 'name': 'input_placeholder', + 'description': 'Text input placeholder', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'input_value', + 'type': 'string', + 'name': 'input_value', + 'description': 'Text input current value', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'select_visible', + 'type': 'string', + 'name': 'select_visible', + 'description': 'Whether to show the select component', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'select_placeholder', + 'type': 'string', + 'name': 'select_placeholder', + 'description': 'Select placeholder', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + _select_options_variable(), + { + 'id': 'select_options', + 'type': 'array', + 'name': 'select_options', + 'description': 'Legacy select options', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'select_index', + 'type': 'number', + 'name': 'select_index', + 'description': 'Current select index', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + btns_var, + ], + 'formList': [], + 'customContextList': [], + 'expList': [], + 'localList': [], + 'hsfList': [], + 'lwpList': [], + 'pageData': {}, + 'extension': { + 'extendType': 'AI', + # All 5 statuses listed — must mirror the enableX flags on + # AICardContainer. The working reference template's extension + # includes 2 (writing) and 4 (doing); omitting them while + # enableWriting/enableDoing are true makes the renderer reject + # transitions and leaves the card body empty. + 'aiStatusList': [3, 1, 5, 2, 4], + 'fileTypeList': [], + }, + } + + +def main(): + editor_data = build_editor_data() + wrapper = { + 'editorData': json.dumps(editor_data, ensure_ascii=False, separators=(',', ':')), + 'widgetInfo': '', + 'type': 'im', + 'mode': 'card', + } + OUTPUT.write_text(json.dumps(wrapper, ensure_ascii=False, indent=2), encoding='utf-8') + print(f'wrote {OUTPUT}') + + +if __name__ == '__main__': + main() diff --git a/scripts/test-coverage.sh b/scripts/test-coverage.sh new file mode 100755 index 0000000..211ceae --- /dev/null +++ b/scripts/test-coverage.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# Coverage gate script +# Runs all tests with coverage, enforcing minimum coverage threshold +# Uses separate pytest invocations to avoid sys.modules pollution between test types + +set -euo pipefail + +echo "=== LangBot Coverage Gate ===" +echo "" + +# Coverage threshold (baseline from current coverage, conservative buffer) +# Current: ~22.14%, threshold: 18% +COVERAGE_THRESHOLD=18 + +# Create temporary directory for coverage files +COV_DIR=$(mktemp -d) +trap "rm -rf $COV_DIR" EXIT + +echo "[1/3] Running unit + smoke tests with coverage..." +uv run pytest tests/unit_tests/ tests/smoke/ \ + --cov=langbot \ + --cov-report=json:$COV_DIR/unit.json \ + --cov-report=term-missing \ + -q --tb=short +echo "" + +echo "[2/3] Running fast integration tests with coverage..." +uv run pytest tests/integration/ -m "not slow" \ + --cov=langbot \ + --cov-report=json:$COV_DIR/integration.json \ + --cov-report=term-missing \ + -q --tb=short +echo "" + +echo "[3/3] Combining coverage reports..." +# Use coverage combine if available, otherwise just report total +if command -v coverage &> /dev/null; then + # Combine JSON reports + coverage combine --keep $COV_DIR/unit.json $COV_DIR/integration.json \ + --data-file=$COV_DIR/combined.data 2>/dev/null || true + + coverage report --data-file=$COV_DIR/combined.data || true +else + echo "Note: coverage combine not available, showing individual reports above" +fi + +# Generate final XML report for CI (from last run) +uv run pytest tests/unit_tests/ tests/smoke/ \ + --cov=langbot \ + --cov-report=xml:coverage.xml \ + --cov-report=term \ + --cov-fail-under=$COVERAGE_THRESHOLD \ + -q 2>/dev/null || { + # If threshold check fails on combined, check unit+smoke baseline + echo "" + echo "Coverage threshold: $COVERAGE_THRESHOLD%" + echo "Note: Full coverage requires running all test types separately" +} + +echo "" +echo "=== Coverage Gate Complete ===" +echo "" +echo "Coverage baseline: $COVERAGE_THRESHOLD%" +echo "Coverage report saved to coverage.xml" \ No newline at end of file diff --git a/scripts/test-integration-fast.sh b/scripts/test-integration-fast.sh new file mode 100755 index 0000000..6beac87 --- /dev/null +++ b/scripts/test-integration-fast.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Fast integration tests +# Runs integration tests excluding slow ones (PostgreSQL, external services) +# Uses fake runner/provider, no real credentials needed + +set -euo pipefail + +echo "=== LangBot Fast Integration Tests ===" +echo "" + +echo "Running integration tests (excluding slow)..." +uv run pytest tests/integration/ -m "not slow" -q --tb=short + +echo "" +echo "=== Fast Integration Tests Complete ===" \ No newline at end of file diff --git a/scripts/test-quick.sh b/scripts/test-quick.sh new file mode 100755 index 0000000..511c457 --- /dev/null +++ b/scripts/test-quick.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# Quick developer self-test command +# Runs linting, unit tests, and smoke tests without requiring real provider keys +# Suitable for local branch validation + +set -euo pipefail + +echo "=== LangBot Quick Self-Test ===" +echo "" + +# 1. Ruff check +echo "[1/3] Running ruff check..." +uv run ruff check src/langbot/ tests/ --output-format=concise || { + echo "" + echo "⚠ Ruff check found issues. Run 'uv run ruff check --fix' to auto-fix." + exit 1 +} +echo "✓ Ruff check passed" +echo "" + +# 2. Unit tests +echo "[2/3] Running unit tests..." +uv run pytest tests/unit_tests/ -q --tb=short +echo "" + +# 3. Smoke tests (if exists) +echo "[3/3] Running smoke tests..." +if [ -d "tests/smoke" ]; then + uv run pytest tests/smoke/ -q --tb=short +else + echo "No smoke tests found, skipping" +fi +echo "" + +echo "=== Quick Self-Test Complete ===" \ No newline at end of file diff --git a/skills/.gitignore b/skills/.gitignore new file mode 100644 index 0000000..daeb4d9 --- /dev/null +++ b/skills/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +coverage/ +.tap/ +__pycache__/ +*.pyc +skills/.env.local +reports/ +skills/*/reports/ +.browser/ diff --git a/skills/AGENTS.md b/skills/AGENTS.md new file mode 100644 index 0000000..c42412e --- /dev/null +++ b/skills/AGENTS.md @@ -0,0 +1,68 @@ +# Agent Workflow + +This repository stores reusable LangBot agent-testing assets. Keep changes structured so the next agent does not need to rediscover paths. + +## First Steps + +1. Read `skills/.env` before using local URLs, paths, browser profiles, or proxy defaults. If present, `skills/.env.local` overrides it for this machine and must not be committed. On a new machine, copy `skills/.env.example` to `skills/.env.local` first. +2. Pick the smallest relevant skill: + - `langbot-env-setup` for environment, browser, OAuth, proxy, and startup. + - `langbot-testing` for WebUI, provider, pipeline, cases, and troubleshooting. + - `langbot-skills-maintenance` for adding, deduplicating, or auditing this skills repository. +3. Prefer existing cases and troubleshooting entries before exploring from scratch. + +## Editing Rules + +- UI/browser testing is the primary QA path. API/curl checks are diagnostic only and cannot make a UI case pass by themselves. +- Put skills under `skills//`. +- Keep `SKILL.md` concise; move detailed workflows to `references/`. +- Put reusable test paths in `cases/*.yaml`. +- New or edited cases must include `priority`, `risk`, `ci_eligible`, and `evidence_required` so agents can select the right test set without rereading every file. +- Use `env_any` / `automation_env_any` for one-of machine inputs, such as `LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME`; do not list those alternatives as separate all-required env keys. +- Put reusable groups of cases in `suites/*.yaml` rather than hardcoding test sets in docs or CLI code. +- Put growing failure knowledge in `troubleshooting/*.yaml`. +- Do not hardcode local ports in testing docs; use `skills/.env` variables and machine-local `skills/.env.local` overrides. +- Do not store secrets, API keys, OAuth tokens, or localStorage token values. + +## Required Checks + +After structural changes, run: + +```bash +bin/lbs validate +``` + +After changing skills, cases, or troubleshooting assets, run: + +```bash +bin/lbs index +``` + +Use `bin/lbs env show` to inspect defaults and `bin/lbs env doctor` when diagnosing local environment readiness. Env output is redacted by default; do not work around that by printing raw secrets. +`bin/lbs` is a generated local wrapper. If it is missing on a fresh checkout, run `npm run bootstrap` from this directory first; `npm install` also regenerates it via `prepare`. +Use `bin/lbs fixture check` before fixture-heavy cases such as MCP, RAG, multimodal, or plugin smoke tests. +Use `bin/lbs case list --ready` for cases that have no missing machine inputs and no manual preconditions. Use `bin/lbs case list --machine-ready` when you want to keep `manual-check` candidates and confirm their preconditions yourself. + +Before executing a saved QA path, generate the agent-facing plan: + +```bash +bin/lbs test plan +``` + +Read the plan readiness sections before running the browser path. Missing env, +automation env, or fixture readiness means the case is not ready to execute and +should be marked `blocked` or fixed first. +`manual_check` means machine inputs are present but the agent must verify the +declared `preconditions` or `setup` items before executing the UI path. Do not +turn a `manual_check` case into `pass` until those items were checked in the +same run. + +Before executing a group of saved QA paths, generate the suite plan: + +```bash +bin/lbs suite plan +``` + +Use `bin/lbs suite start ` to create a shared suite run id, suite evidence root, per-case evidence directories, and `suite-start.json`/`suite-start.md` handoff files. Then run `bin/lbs suite report --evidence-dir ` to aggregate case results. +Automation scripts write `automation-result.json`; write the final per-case `result.json` with `bin/lbs test result --result --reason --evidence-dir --evidence ` after collecting the required evidence. A `pass` result must include all required evidence. +For runner-specific Debug Chat cases, prefer case-specific pipeline env keys such as `LANGBOT_LOCAL_AGENT_PIPELINE_URL` over the generic `LANGBOT_PIPELINE_URL`; otherwise an agent can accidentally test the wrong runner. diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..091e3d3 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,59 @@ +# LangBot Skills + +This directory is the **single source of truth** for LangBot's agent skills — +reusable, on-demand instruction packs for AI agents (Claude Code, Codex, Cursor, +and LangBot's own Local Agent) working with the LangBot ecosystem. + +> These skills were consolidated here from the former `langbot-app/langbot-skills` +> repository (now archived). Documentation and the landing page link here; do not +> re-copy skill content elsewhere — link to this directory instead. + +## Skill catalog + +| Skill | What it covers | +| --- | --- | +| [`langbot-dev`](skills/langbot-dev) | Core backend + web frontend development (Quart, Vite, API, migrations, MCP server) | +| [`langbot-plugin-dev`](skills/langbot-plugin-dev) | Plugin SDK / component development, debugging, WebSocket testing | +| [`langbot-deploy`](skills/langbot-deploy) | Docker / Compose / Kubernetes deployment, config.yaml, Box runtime, global API key | +| [`langbot-testing`](skills/langbot-testing) | WebUI / e2e QA harness, cases, fixtures, troubleshooting (the `bin/lbs` CLI) | +| [`langbot-env-setup`](skills/langbot-env-setup) | Local dev/test environment, browser access, OAuth, proxy, startup | +| [`langbot-mcp-ops`](skills/langbot-mcp-ops) | Operating a LangBot instance through its MCP server (`/mcp`) | +| [`langbot-space-ops`](skills/langbot-space-ops) | Browsing the LangBot Space marketplaces through the Space MCP server | +| [`langbot-eba-adapter-dev`](skills/langbot-eba-adapter-dev) | Building platform adapters for the Event-Based Agents architecture | +| [`langbot-skills-maintenance`](skills/langbot-skills-maintenance) | Adding, deduplicating, and auditing skills in this directory | + +`skills.index.json` is the machine-readable index (regenerate with `bin/lbs index`). + +## Quick start (for an AI agent) + +1. Read this README, `AGENTS.md`, and `docs/user-guide.md` to understand the layout. +2. Read `skills/.env` for shared local defaults. On a new machine, copy + `skills/.env.example` to `skills/.env.local` (gitignored) and override + machine-specific values there. Never commit secrets. +3. Pick the smallest relevant skill from the catalog above and follow its + `SKILL.md`. + +## The `lbs` CLI + +The testing assets ship with a small CLI (`bin/lbs`, Node >= 22.6). The +`bin/lbs` wrapper is a generated local entrypoint; on a fresh checkout, run +`npm run bootstrap` once if it is missing. `npm install` also regenerates it via +the `prepare` script. + +```bash +npm run bootstrap # create bin/lbs if missing +bin/lbs validate # validate skills/cases/troubleshooting structure +bin/lbs index # regenerate skills.index.json +bin/lbs env show # inspect resolved env defaults (redacted) +bin/lbs env doctor # diagnose local environment readiness +bin/lbs case list --ready +bin/lbs test plan +bin/lbs suite plan langbot-debug-chat-load-gate +``` + +## Maintenance rule + +When the LangBot / LangBot Space **API or MCP server changes**, the +corresponding skill here MUST be updated in the same change. The MCP tool +surface, the API, and these skills are kept in lockstep — see each repo's +`AGENTS.md`. diff --git a/skills/docs/user-guide.md b/skills/docs/user-guide.md new file mode 100644 index 0000000..124d3af --- /dev/null +++ b/skills/docs/user-guide.md @@ -0,0 +1,171 @@ +# LangBot QA Skills User Guide + +Use this guide as the first operational path after reading `README.md` and +`AGENTS.md`. + +## 1. Configure Local Inputs + +Read `skills/.env`, then create `skills/.env.local` for machine-local values. +Do not commit `.env.local`, browser profiles, reports, tokens, API keys, OAuth +state, or provider credentials. + +Minimum local fields for live browser QA: + +```bash +LANGBOT_REPO=/path/to/LangBot +LANGBOT_WEB_REPO=/path/to/LangBot/web +LANGBOT_BACKEND_URL=http://127.0.0.1:5300 +LANGBOT_FRONTEND_URL=http://127.0.0.1:3000 +LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:3000 +LANGBOT_BROWSER_PROFILE=/path/to/langbot-browser-profile +LANGBOT_CHROMIUM_EXECUTABLE=/path/to/chromium-or-playwright-chrome +LANGBOT_E2E_LOGIN_USER=qa-local@example.com +``` + +`LANGBOT_E2E_LOGIN_USER` is a local QA account. The setup automation uses the +LangBot recovery key from the active checkout to initialize or refresh that +local account and write a browser `localStorage` token. It does not need the +user's GitHub or Space credentials. + +## 2. Check Readiness + +From `skills/`: + +```bash +bin/lbs env show +bin/lbs env doctor +bin/lbs validate +bin/lbs index --check +``` + +`env doctor` should report reachable backend and frontend URLs before live +browser cases are run. Missing Space provider credentials are not a LangBot +product pass; classify them as `env_issue` and configure the local Space +provider before measuring Debug Chat performance. + +## 3. Start Services + +Start the backend from `LANGBOT_REPO`: + +```bash +cd "$LANGBOT_REPO" +uv run main.py +``` + +Start the standalone frontend from `LANGBOT_WEB_REPO` and point it at the +backend: + +```bash +cd "$LANGBOT_WEB_REPO" +VITE_API_BASE_URL="$LANGBOT_BACKEND_URL" pnpm dev --host 0.0.0.0 +``` + +If `VITE_API_BASE_URL` is missing, browser tests can load the Vite page but send +API requests to the frontend port, which produces false UI failures. + +## 4. Prepare User-Path Fixtures + +For local-agent Debug Chat cases and the user-path performance gate: + +```bash +node scripts/e2e/ensure-local-agent-pipeline.mjs --write-env +``` + +The script: + +- refreshes the local QA login and browser token; +- marks the local wizard as skipped; +- creates or updates a local QA pipeline; +- scans Space LLM models, tests candidates, and switches to the first working + Space model with tested fallback models; +- writes `LANGBOT_PIPELINE_URL`, `LANGBOT_PIPELINE_NAME`, and local-agent + pipeline/model variables into `skills/.env.local`; +- returns `env_issue` when no Space model can be scanned or tested. + +Useful model controls: + +```bash +LANGBOT_E2E_MODEL_TEST_LIMIT=8 +LANGBOT_E2E_MODEL_FALLBACK_COUNT=3 +LANGBOT_E2E_SKIP_MODEL_UUIDS=uuid-a,uuid-b +LANGBOT_E2E_SKIP_MODEL_NAMES=model-a,model-b +LANGBOT_E2E_SCAN_SPACE_MODELS=true +``` + +The setup writes a current-runtime compatibility `max-round` value into the +pipeline config because this backend still reads that field directly during +message truncation. Do not treat it as a long-term QA contract. + +## 5. Run Gates + +Fast contract gate, no live service required: + +```bash +bin/lbs suite run langbot-performance-contract-gate --run-id langbot-contract-local +``` + +Live backend gate: + +```bash +bin/lbs suite run langbot-live-backend-gate --run-id langbot-backend-local +``` + +Browser-visible user-path performance gate: + +```bash +bin/lbs suite plan langbot-user-path-performance-gate +bin/lbs suite run langbot-user-path-performance-gate --run-id langbot-user-path-local --include-manual-check +``` + +Controlled Debug Chat message-path load gate (manual/non-required; run fake-provider cases serially when they share `LANGBOT_FAKE_PROVIDER_URL`): + +```bash +bin/lbs suite plan langbot-debug-chat-load-gate +bin/lbs test run langbot-fake-provider-debug-chat-load --run-id langbot-fake-load-local +bin/lbs test run langbot-fake-provider-debug-chat-slow-load --run-id langbot-fake-slow-local +bin/lbs test run langbot-fake-provider-debug-chat-fault-recovery --run-id langbot-fake-fault-local +bin/lbs test run langbot-space-debug-chat-concurrency-smoke --run-id langbot-space-smoke-local +``` + +Cross-pipeline Debug Chat isolation is a separate manual regression gate because +current releases may fail it due to product bug #2286: + +```bash +bin/lbs suite plan langbot-debug-chat-isolation-gate +bin/lbs suite run langbot-debug-chat-isolation-gate --run-id langbot-debug-chat-isolation-local --include-manual-check +``` + +Start with `langbot-fake-provider-debug-chat-load`. It launches a local +OpenAI-compatible fake provider, creates the matching provider/model/pipeline, +then sends concurrent WebSocket Debug Chat messages through the real backend. +Use `langbot-fake-provider-debug-chat-slow-load` to measure the same path under +deterministic streaming latency. Use +`langbot-fake-provider-debug-chat-fault-recovery` to inject bounded provider +HTTP failures and confirm later Debug Chat requests recover. Use the separate +`langbot-debug-chat-isolation-gate` to verify that concurrent Debug Chat traffic +on two pipelines does not leak assistant responses across pipeline boundaries; +current releases may fail that gate because of #2286, so keep it out of the +normal load gate until the product fix lands. +Use `langbot-space-debug-chat-concurrency-smoke` only as a low-volume live +provider smoke; it includes Space/model/network latency and should be compared +against the fake-provider baseline before attributing failures to LangBot. + +`manual_check` means the agent must confirm the declared preconditions for that +run window. When setup automation is declared, run output may stop early with +`env_issue`; fix that environment input before treating the product path as +measured. + +## 6. Read Results + +Suite reports live under `skills/reports/`. Evidence lives under +`skills/reports/evidence//`. + +For performance cases, inspect: + +- `metrics.json` for p50/p95/p99, error rate, and total duration; +- `automation-result.json` for threshold decisions and artifacts; +- `console.log` and `network.log` for frontend/API failures; +- backend logs for provider, runner, WebSocket, or persistence failures. + +Do not call a user-path performance result a LangBot overhead regression until +provider/tool/network time has been separated or ruled out. diff --git a/skills/package.json b/skills/package.json new file mode 100644 index 0000000..4b9f42b --- /dev/null +++ b/skills/package.json @@ -0,0 +1,29 @@ +{ + "private": true, + "type": "module", + "bin": { + "lbs": "./bin/lbs" + }, + "scripts": { + "bootstrap": "node scripts/bootstrap-lbs.mjs", + "prepare": "node scripts/bootstrap-lbs.mjs", + "prevalidate": "node scripts/bootstrap-lbs.mjs", + "preindex": "node scripts/bootstrap-lbs.mjs", + "preindex:check": "node scripts/bootstrap-lbs.mjs", + "pretest": "node scripts/bootstrap-lbs.mjs", + "precheck": "node scripts/bootstrap-lbs.mjs", + "lbs": "node src/lbs.ts", + "test": "node test/lbs-cli.test.ts", + "validate": "node src/lbs.ts validate", + "index": "node src/lbs.ts index", + "index:check": "node src/lbs.ts index --check", + "check:syntax": "find src test scripts -type f \\( -name '*.ts' -o -name '*.mjs' \\) -print0 | xargs -0 -n1 node --check", + "check": "npm run check:syntax && npm run validate && npm test" + }, + "engines": { + "node": ">=22.6" + }, + "devDependencies": { + "playwright": "^1.60.0" + } +} diff --git a/skills/qa-agent-docs/qa-agent/00-technology-options.md b/skills/qa-agent-docs/qa-agent/00-technology-options.md new file mode 100644 index 0000000..0a021f4 --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/00-technology-options.md @@ -0,0 +1,117 @@ +# LangBot Agent Testing 技术选型 + +## 状态 + +这是技术选型背景文档,不是当前路线图。当前黑盒 E2E QA 的实施顺序见: + +```text +docs/qa-agent/04-black-box-e2e-roadmap.md +``` + +## 目标 + +`langbot-skills` 的目标不是替代测试框架,而是沉淀 agent 可复用的测试资产,让开发者 clone 仓库后,可以让 Codex、Claude Code、Computer Use 或 Playwright MCP 复用已有路径完成 LangBot 功能验证。 + +核心原则: + +- Skill 负责路由和少量规则。 +- Reference 负责可读流程和背景知识。 +- Case 负责结构化测试路径。 +- Troubleshooting 负责结构化故障资产。 +- `lbs` 负责结构校验、索引、资产创建和未来的运行/报告能力。 +- UI/browser 是产品 QA 的主路径;API/curl 只用于诊断。 + +## 浏览器控制层 + +不同开发者可用的浏览器控制能力不同,所以浏览器层必须可替换。 + +| 方案 | 适用场景 | 优点 | 代价 | +|---|---|---|---| +| Codex / Claude Computer Use | agent 可以直接控制可见浏览器 | 登录和交互路径最自然,通常不需要额外 MCP 浏览器桥接 | 依赖具体 agent 工具能力 | +| Playwright MCP | 没有 Computer Use,但有 MCP 浏览器工具 | 稳定、可脚本化、适合回归路径 | OAuth 登录通常需要额外 visible profile | +| 直接 Playwright 脚本 | 测试路径非常稳定,适合 CI | 可重复性强 | 需要维护脚本和 selector | +| 商业 AI QA 平台 | 团队希望外包测试运行平台 | 报告和 PR 集成完整 | 成本和平台绑定 | + +## 当前推荐 + +先采用分层降级: + +```text +有 Computer Use? + 是 -> 使用 Computer Use 控制浏览器 + 否 -> 使用 Playwright MCP + +需要 GitHub OAuth? + 是 -> 使用持久浏览器 profile,让用户手动完成登录 + 否 -> 直接使用已有登录态或测试账号状态 +``` + +具体选择逻辑沉淀在: + +```text +skills/langbot-env-setup/references/browser-access-selection.md +``` + +测试原则固定在: + +```text +docs/qa-agent/03-agent-browser-qa-principles.md +``` + +## 环境变量层 + +测试文档不应写死端口。共享默认值放在: + +```text +skills/.env +``` + +关键变量: + +```text +LANGBOT_FRONTEND_URL +LANGBOT_BACKEND_URL +LANGBOT_DEV_FRONTEND_URL +LANGBOT_REPO +LANGBOT_WEB_REPO +LANGBOT_BROWSER_PROFILE +``` + +Agent 执行测试前应先读取 `skills/.env`,再用用户提供的当前环境或已启动服务覆盖默认值。 + +## 测试资产层 + +测试资产分两类: + +```text +skills// + references/ # Markdown 流程说明 + cases/ # 结构化测试用例 + troubleshooting/ # 结构化故障记录 +``` + +当前已实现: + +- `SKILL.md` 路由 +- `references/*.md` +- `lbs case new/list/show` +- `lbs trouble show/search` +- `lbs test plan` +- `lbs test report` +- `lbs list / validate / index` + +下一步重点: + +- 日志守卫规则补充 +- 报告产物管理 + +## 关键判断 + +不要强制所有内容只能通过 CLI 修改。更好的模式是: + +- 新增 case/troubleshooting:优先使用 `lbs` +- 大段流程说明:允许直接编辑 Markdown +- 结构性变更后:必须运行 `lbs validate` +- 任何生成索引的变更后:运行 `lbs index` + +这样既能沉淀结构化资产,又不会在 schema 未稳定时拖慢迭代。 diff --git a/skills/qa-agent-docs/qa-agent/01-qa-agent-harness-plan.md b/skills/qa-agent-docs/qa-agent/01-qa-agent-harness-plan.md new file mode 100644 index 0000000..06b52ee --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/01-qa-agent-harness-plan.md @@ -0,0 +1,231 @@ +# LangBot Skills 测试资产库规划 + +## 状态 + +这是早期测试资产库规划文档,保留用于解释 `langbot-skills` 的分层来源。 + +当前路线已经收敛为黑盒 E2E QA:开发者用 agent 通过浏览器测试 LangBot, +稳定路径沉淀为 case,失败知识沉淀为 troubleshooting。`lbs test report` 和 +日志守卫已有 MVP,后续重点是报告证据、case 元数据和少量稳定路径自动化。当前优先级见: + +```text +docs/qa-agent/04-black-box-e2e-roadmap.md +``` + +本文中关于 `case list/show`、`trouble show/search`、`test plan` 的“计划实现” +内容已经部分过时,因为这些能力已经落地。 + +## 目标 + +让开发者 clone `langbot-skills` 后,可以把测试意图交给 agent,由 agent 复用已有环境配置、测试路径和故障知识完成 LangBot 功能验证。 + +典型场景: + +- 冒烟测试:验证 pipeline Debug Chat、provider、常见页面是否正常。 +- Provider 测试:添加 DeepSeek/OpenAI/Claude 等供应商并验证模型可用。 +- 新 feature 测试:探索新 UI 路径,并在稳定后沉淀成 case/reference。 +- 回归测试:复用旧路径,避免每个窗口重新探索登录、模型配置、pipeline 调试。 +- 故障沉淀:把 runtime 超时、代理不一致、WebSocket 问题记录为可搜索资产。 + +核心方向见 `03-agent-browser-qa-principles.md`:agent 必须以浏览器/UI 为主路径,API/curl 只能作为诊断手段。 + +## 当前仓库结构 + +```text +skills/ + .env # 共享默认变量 + langbot-env-setup/ # 环境准备、浏览器控制路径、代理、登录态 + langbot-testing/ # WebUI / provider / pipeline 测试入口 + langbot-plugin-dev/ # 插件开发测试 + langbot-eba-adapter-dev/ # 平台适配器开发测试 +src/ + lbs.ts # CLI 源码 +bin/ + lbs # CLI 入口 +docs/ + qa-agent/ # 规划文档,历史目录名保留 +``` + +## 设计分层 + +### 1. Skill 层 + +`SKILL.md` 只做触发和路由,不承载大段流程。 + +例子: + +```text +langbot-env-setup -> 选择 Computer Use / Playwright MCP / OAuth profile / proxy +langbot-testing -> 选择 WebUI / pipeline / provider / troubleshooting +``` + +### 2. Reference 层 + +Markdown 记录人和 agent 都能读的流程说明。 + +适合内容: + +- 如何选择浏览器控制方式 +- 如何启动/检查服务 +- 如何执行 pipeline Debug Chat +- 如何处理 OAuth 登录态 + +### 3. Case 层 + +使用 YAML 记录可重复测试路径。 + +建议结构: + +```text +skills/langbot-testing/cases/ + pipeline-debug-chat.yaml + provider-deepseek.yaml +``` + +建议格式: + +```yaml +id: pipeline-debug-chat +title: Pipeline Debug Chat returns a bot response +mode: agent-browser +area: pipeline +type: smoke +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +steps: + - Open LANGBOT_FRONTEND_URL + - Navigate to Pipelines + - Open target pipeline + - Select Debug Chat + - Send deterministic prompt +checks: + - "UI: User message appears" + - "UI: Bot message appears" + - "Console: No unexpected frontend errors" + - "Logs: Backend log includes Conversation(0) Streaming completed" +diagnostics: + - "Use API/curl only after the UI path is attempted, to distinguish frontend display failure from backend/runtime failure." +troubleshooting: + - plugin-runtime-timeout + - proxy-env-mismatch +``` + +### 4. Troubleshooting 层 + +故障资产会逐渐变大,适合结构化记录。 + +历史 Markdown 入口保留在: + +```text +skills/langbot-testing/references/troubleshooting.md +``` + +当前 canonical 结构化故障资产在: + +```text +skills/langbot-testing/troubleshooting/ + plugin-runtime-timeout.yaml + proxy-env-mismatch.yaml +``` + +### 5. CLI 层 + +`lbs` 是统一入口,不再引入独立 `qa` 命令。 + +已实现或当前可用: + +```bash +bin/lbs list +bin/lbs validate +bin/lbs index +bin/lbs new-skill +bin/lbs new-ref +bin/lbs case new pipeline-debug-chat --title "Pipeline Debug Chat" +bin/lbs case list +bin/lbs case show pipeline-debug-chat +bin/lbs trouble list +bin/lbs trouble show plugin-runtime-timeout +bin/lbs trouble search runtime +bin/lbs trouble add --title ... --symptom ... --cause ... --fix ... +bin/lbs test plan pipeline-debug-chat +bin/lbs test start pipeline-debug-chat +bin/lbs test run pipeline-debug-chat --dry-run +bin/lbs test report pipeline-debug-chat +bin/lbs test report pipeline-debug-chat --backend-log /path/to/backend.log +``` + +## 测试库位置 + +不要使用隐藏 `.qa/` 作为主测试库。测试资产应该和 skill 放在一起,便于触发和维护: + +```text +skills/langbot-testing/ + references/ + cases/ + troubleshooting/ + reports/ # 可选,本地运行产物可按需忽略或输出到外部目录 +``` + +如果未来需要项目本地测试库,可以允许 `lbs` 支持 `--workspace` 或项目根目录配置,但 canonical 资产仍保存在 `langbot-skills`。 + +## 阶段规划 + +### 阶段一:环境和测试路径沉淀 + +状态:基本完成,持续维护。 + +- `skills/.env` 管共享默认变量。 +- `langbot-env-setup` 拆出 Computer Use、Playwright MCP、OAuth profile、proxy、service startup。 +- `langbot-testing` 记录 WebUI、pipeline、provider 测试路径。 +- `lbs validate/index` 维护结构。 + +完成标准: + +- agent 可以从 `skills/.env` 和 references 中找到当前测试入口。 +- pipeline Debug Chat 这类路径不再需要从头探索。 + +### 阶段二:结构化 case/troubleshooting + +状态:主体已完成,继续补齐元数据和资产质量。 + +目标: + +- `lbs case new/list/show` +- `lbs trouble show/search` +- case id 去重、字段校验、索引生成 + +完成标准: + +- 冒烟测试路径可以用结构化 case 表示。 +- 下一个 agent 窗口可以直接读取 case 执行。 + +### 阶段三:计划和报告 + +状态:已有 MVP,继续完善。 + +目标: + +- `lbs test plan ` +- agent 按 plan 使用浏览器执行 UI QA +- `lbs test report` +- 日志守卫集成 +- 报告产物和 evidence 约定 + +完成标准: + +- agent 可以按 case plan 执行浏览器测试。 +- 结果报告包含 UI 结果、后端日志、console 错误和 troubleshooting 建议。 + +## 执行规则 + +- agent 可以直接编辑 Markdown reference。 +- 新增结构化 case/troubleshooting 时,优先使用 `lbs`。 +- 每次结构变更后运行 `bin/lbs validate`。 +- 每次索引相关变更后运行 `bin/lbs index`。 +- 测试文档不写死端口,使用 `skills/.env` 中的 URL 变量。 +- 测试 case 的 `mode` 固定为 `agent-browser`。 +- API/curl 只能写入 `diagnostics`,不能替代 UI 步骤和 UI 检查。 diff --git a/skills/qa-agent-docs/qa-agent/02-log-guard-plan.md b/skills/qa-agent-docs/qa-agent/02-log-guard-plan.md new file mode 100644 index 0000000..a166b13 --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/02-log-guard-plan.md @@ -0,0 +1,161 @@ +# 日志守卫规划 + +## 状态 + +这是当前活跃设计,已有第一版文件扫描 MVP。实现边界需要和黑盒 E2E 路线保持一致: + +- 日志守卫服务于 `lbs test report`。 +- 它不替代浏览器/UI 判断。 +- 它不发展成独立后端 API 测试框架。 +- 第一版默认扫描 `LANGBOT_REPO/data/logs/` 下最新的 `langbot-*.log`,也可扫描 agent + 显式提供的 backend/frontend/console 日志文件。 + +当前总体路线见: + +```text +docs/qa-agent/04-black-box-e2e-roadmap.md +``` + +## 目标 + +日志守卫是 `lbs test report` 的一部分,用来在 agent 执行测试期间捕获 UI 断言之外的运行时问题。 + +当前命令方向已收敛为 `lbs test plan` / `lbs test report`。日志守卫服务于 agent-browser QA,不是独立的后端 API 测试入口。 + +LangBot 是异步且集成度高的系统,有些问题不会直接表现为页面失败: + +- 后台任务异常 +- 未等待的协程 +- Provider 流式调用失败 +- 插件 runtime 超时 +- 平台发送失败 +- 数据库连接问题 +- 敏感信息泄露 + +日志守卫负责把这些信号结构化地放进测试报告,并关联到 troubleshooting 资产。 + +## 输入 + +日志守卫应从环境和运行上下文读取配置: + +- `skills/.env` 中的 `LANGBOT_BACKEND_URL` +- `skills/.env` 中的 `LANGBOT_REPO`,用于自动发现 LangBot 后端日志 +- `lbs test plan` / report 记录的 case id +- LangBot 后端进程输出 +- 前端 dev server 输出 +- 浏览器 console/network 错误 +- case 声明的 success/failure patterns 和 expected failures + +## MVP 范围 + +- 读取一个或多个日志流或日志文件。 +- 检测错误模式。 +- 支持按 case id 或 pattern 白名单。 +- 输出 JSON/Markdown 摘要。 +- 发现非预期错误时让测试报告标记失败;未来如果有自动执行器,再返回非零退出码。 + +## 错误分类 + +### 永远非预期 + +除非 case 明确声明,否则应失败: + +- `Traceback` +- `Task exception was never retrieved` +- `RuntimeWarning: coroutine .* was never awaited` +- `Unclosed client session` +- `Unclosed connector` +- `KeyError` +- `TypeError` +- `AttributeError` +- 密钥、token、secret 明文泄露 + +### Case 预期错误 + +只有当前 case 声明时允许: + +- 无效 provider key +- Provider 认证失败 +- 无效 webhook payload +- 插件测试故意抛错 +- 超时测试 +- 限流测试 + +### 仅警告 + +报告但默认不失败: + +- 可恢复重试 +- 恢复的超时 +- 废弃配置 +- 慢请求 +- 版本检查失败 + +## 与 Troubleshooting 集成 + +日志守卫不只输出错误文本,还应尽量匹配已知 troubleshooting id。 + +例子: + +```text +Action list_plugins call timed out +Action list_agent_runners call timed out +Action invoke_llm_stream call timed out +``` + +可映射到: + +```text +plugin-runtime-timeout +``` + +```text +uppercase proxy points to one host, lowercase proxy points to another +``` + +可映射到: + +```text +proxy-env-mismatch +``` + +## 未来命令 + +```bash +bin/lbs test plan pipeline-debug-chat +bin/lbs test start pipeline-debug-chat +bin/lbs test run pipeline-debug-chat --dry-run +bin/lbs test report pipeline-debug-chat +bin/lbs test report --output report.md +bin/lbs test report pipeline-debug-chat --backend-log /path/to/backend.log --console-log /path/to/console.log +bin/lbs test report pipeline-debug-chat --since "2026-05-21T10:30:00+08:00" +bin/lbs test report pipeline-debug-chat --tail-lines 2000 +bin/lbs test report pipeline-debug-chat --since "2026-05-21T10:30:00+08:00" --tail-lines 2000 +bin/lbs test report pipeline-debug-chat --no-auto-log +``` + +运行报告应包含: + +- case id +- URL 和环境变量摘要,不能包含 secrets +- 浏览器可见结果 +- 后端日志摘要 +- console/network 错误 +- 匹配到的 troubleshooting id +- 通过/失败结论 + +## MVP 完成标准 + +- 可以自动扫描最新 LangBot 后端日志,也可以扫描前端日志和 console 日志文件。 +- 可以用 `--since` 或 `--tail-lines` 把扫描范围限制到本次测试窗口。 +- 可以检测明显 Python/运行时错误和 secret 泄露风险。 +- 可以识别 case 声明的 success/failure patterns。 +- 可以识别 troubleshooting pattern,包括 `plugin-runtime-timeout` 和 `proxy-env-mismatch`。 +- 支持 case 级白名单。 +- 输出机器可读摘要。 +- 至少一个 `langbot-testing` case 使用它。 + +当前 MVP 已覆盖自动发现 LangBot 后端日志、文件扫描、`--since`/`--tail-lines` 扫描窗口、 +基础错误检测、case success/failure signal、troubleshooting 匹配、secret 脱敏和 `--json` +输出。仍待继续完善的是 live log 采集、更多规则、case 级 expected failure 的资产化和真实 +E2E report 样例。 diff --git a/skills/qa-agent-docs/qa-agent/03-agent-browser-qa-principles.md b/skills/qa-agent-docs/qa-agent/03-agent-browser-qa-principles.md new file mode 100644 index 0000000..15a18c2 --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/03-agent-browser-qa-principles.md @@ -0,0 +1,57 @@ +# Agent Browser QA Principles + +This document fixes the direction of LangBot agent testing so the project does not drift into a backend API smoke-test framework. + +## Primary Goal + +`langbot-skills` should help an agent behave like a QA engineer using the product, not like a backend curl script. + +The primary path is: + +```text +developer intent -> lbs test plan -> agent controls browser -> UI result + console + logs -> report/assets +``` + +## Rules + +1. Browser/UI interaction is the source of truth for product QA cases. +2. A backend API or curl response is never enough to mark a UI case passed. +3. API/curl/log checks are allowed as diagnostics after a UI path is attempted or when debugging environment readiness. +4. A case passes only when the user-visible UI result is correct. +5. The agent should inspect browser console/network output when available. +6. If screenshot or vision capability is available, the agent should check for blank pages, overlap, hidden actions, broken layout, and error toasts. +7. If no visual model is available, use DOM/accessibility snapshots and console output instead. +8. New stable UI paths should be added as `cases/*.yaml`. +9. New recurring failure modes should be added as `troubleshooting/*.yaml`. +10. Secrets, tokens, API keys, and localStorage token values must never be printed. + +## Command Semantics + +`lbs` manages assets and produces plans. It does not replace the agent's browser-control ability. + +```bash +bin/lbs test plan pipeline-debug-chat +``` + +This command outputs: + +- environment variables to use +- required skills +- browser steps +- UI/console/visual/log checks +- diagnostic options +- related troubleshooting patterns +- report template + +The active agent then executes the plan with Computer Use, Playwright MCP, or another available browser-control tool. + +## Diagnostics + +Diagnostics can include: + +- `bin/lbs env doctor` +- browser console/network inspection +- backend logs +- targeted API/curl checks + +Diagnostics answer "where did it fail?" They do not replace "did the user-visible UI work?" diff --git a/skills/qa-agent-docs/qa-agent/04-black-box-e2e-roadmap.md b/skills/qa-agent-docs/qa-agent/04-black-box-e2e-roadmap.md new file mode 100644 index 0000000..4d86525 --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/04-black-box-e2e-roadmap.md @@ -0,0 +1,299 @@ +# 黑盒 E2E QA 路线图 + +## 定位 + +LangBot 有大量外部依赖:模型供应商、plugin runtime、浏览器登录态、 +marketplace/network、RAG engine、sandbox backend、平台适配器等。单测仍然有价值, +但这个 QA 方向当前不优先解决 LangBot core 的单测覆盖率问题,因为重 mock 往往不能 +真实代表产品路径。 + +`langbot-skills` 当前目标是让黑盒 E2E 测试变得可执行、可沉淀、可复用: + +```text +开发者测试意图 +-> 复用或新增 case +-> agent 通过浏览器执行 +-> UI + console + network + log 证据 +-> report +-> 反哺 case / troubleshooting +``` + +这是面向开发者的 QA 资产库。开发者可以让 agent 测一个 feature;如果路径稳定, +就把路径正规化为 case,让下一个开发者或 QA agent 继续复用。 + +## 非目标 + +- 这一阶段不优先建设 LangBot core 单测覆盖率。 +- 不把 API/curl 作为 WebUI 行为的通过标准。 +- 不要求每个 case 都能进 CI。 +- 不在 report 和日志守卫有用之前急着做完整 browser runner。 +- 不把外部 provider、OAuth、marketplace 抖动直接判成产品失败,除非证据明确。 + +## 当前状态 + +仓库已经具备第一层基础设施: + +- `skills/.env` 和 `skills/.env.local` 管理测试环境; +- `langbot-env-setup`、`langbot-testing`、`langbot-plugin-dev` 等 skill; +- `skills/langbot-testing/cases` 下的结构化 case; +- `skills/langbot-testing/troubleshooting` 下的结构化故障资产; +- RAG、多模态、plugin、MCP 等 fixture; +- `bin/lbs validate`、`bin/lbs index`、`bin/lbs case`、`bin/lbs trouble`、 + `bin/lbs test plan`、`bin/lbs test start`、`bin/lbs test report`。 + +所以当前已经不是“先把路径写进 Markdown”的阶段,而是进入“让每次运行有证据、 +有报告、能沉淀”的阶段。 + +## 测试模型 + +UI case 只有在用户可见行为正确时才能通过。辅助证据必须解释同一次运行。 + +通过一个 UI case 的最低证据: + +- 用户可见的成功信号,例如 bot 回复、provider 保存成功、文件上传完成、plugin 页面渲染; +- 没有意外 browser console error; +- 相关时间窗口内没有意外后端/runtime 错误; +- 有截图、DOM snapshot 或同等视觉/结构证据,如果当前 agent 能获取; +- API/curl 只在解释同一条 UI 路径时作为诊断证据。 + +失败报告需要保留足够信息,让开发者能复现或分流: + +- case id 和实际测试 URL; +- 使用的 browser path; +- 最后可见 UI 状态; +- console/network 症状; +- 相关后端/前端日志; +- 匹配到的 troubleshooting id; +- 这是产品失败、环境问题、外部依赖抖动,还是证据不足。 + +## 结果词汇 + +统一使用这些结果: + +- `pass`:UI 行为正确,辅助证据干净。 +- `fail`:UI 行为错误,或同一次运行的 console/log 出现意外产品错误。 +- `blocked`:缺登录、缺 provider credentials、服务未启动等原因导致目标路径没有跑起来。 +- `env_issue`:失败在目标行为之外,例如 proxy、OAuth、provider quota、marketplace outage、 + 本地服务启动问题。 +- `flaky`:同一环境下结果不稳定,进入门禁前需要先稳定。 + +做 merge/release 判断时,`env_issue` 和 `blocked` 不能算产品通过。 + +## 路线图 + +### Phase 0:对齐文档 + +目标:明确当前黑盒 E2E 方向。 + +交付物: + +- `docs/qa-agent/README.md` 文档状态导航; +- 本路线图; +- 给旧规划文档加状态说明。 + +完成标准: + +- 新贡献者不用通读所有旧文档,也能知道当前重点。 + +### Phase 1:Test Report MVP + +状态:已有第一版。 + +目标:让每次 agent browser 测试都有一致报告格式,即使 browser 执行还没自动化。 + +建议命令: + +```bash +bin/lbs test start +bin/lbs test report --output reports/-.md +``` + +MVP 行为: + +- 读取 case 和关联 troubleshooting; +- 生成 Markdown report 模板; +- 生成 run handoff,固定本次测试的 start timestamp 和推荐 report command; +- 写入脱敏后的环境摘要; +- 提供 `pass/fail/blocked/env_issue/flaky` 结果选项; +- 包含 UI result、console errors、network symptoms、logs、screenshots、 + diagnostics、matched troubleshooting、assets to update 等 section; +- 支持 `--json`,输出机器可读报告。 + +第一版已经是 report generator,不急着做自动判定。先把 evidence 收集格式统一起来, +再做自动化更稳。 + +完成标准: + +- agent 可以先跑 `lbs test start `,用它给出的时间窗口执行浏览器路径, + 然后按固定格式填写 report,不需要每次重新发明报告结构。 + +### Phase 2:日志守卫 MVP + +状态:已有第一版文件扫描。 + +目标:捕获 UI 不一定明显展示的 runtime 问题。 + +日志守卫应集成进 `lbs test report`,不要发展成独立后端 API 测试框架。 + +建议命令形态: + +```bash +bin/lbs test report \ + --backend-log /path/to/backend.log \ + --frontend-log /path/to/frontend.log \ + --console-log /path/to/console.log \ + --evidence-dir reports/evidence/ \ + --since "2026-05-21T10:30:00+08:00" \ + --tail-lines 2000 \ + --output reports/-.md +``` + +MVP 行为: + +- 默认从 `LANGBOT_REPO/data/logs/` 扫描最新 `langbot-*.log`; +- 支持 agent 显式提供 backend、frontend、console 日志文件; +- 支持读取 evidence 目录下的 `automation-result.json`,把浏览器自动化脚本结论纳入报告; +- 支持 `lbs test result` 为人工/agent browser 运行写入标准 `result.json`,供 suite 聚合; +- 支持 `--since` 和 `--tail-lines`,避免历史日志污染本次报告; +- 检测默认非预期模式,例如 `Traceback`、未 await coroutine、unclosed client/connector、 + `KeyError`、`TypeError`、`AttributeError`、明显 secret 泄露; +- 匹配 case 声明的 `success_patterns` 和 `failure_patterns`; +- 匹配已知 troubleshooting,先支持 `plugin-runtime-timeout` 和 `proxy-env-mismatch`; +- 只有 case 明确声明时,才允许 expected failure; +- 将发现分类为 fail、warning、matched troubleshooting、ignored expected issue; +- 永远不打印 secret 值。 + +完成标准: + +- 至少 `pipeline-debug-chat` 能生成包含日志摘要和 troubleshooting 匹配结果的 report。 + +### Phase 3:Case 元数据加固 + +状态:已有第一版。 + +目标:让 case 更容易选择、执行和晋级。 + +字段逐步补充,保持向后兼容: + +```yaml +priority: p0 | p1 | p2 +risk: low | medium | high +ci_eligible: false +preconditions: + - "Authenticated browser profile is available." +setup: + - "Start LangBot backend and frontend." +cleanup: + - "Remove temporary provider, plugin, or knowledge base if created." +expected_failures: [] +success_patterns: + - "Conversation(0) Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" +evidence: + required: + - ui + - console + - backend_log +``` + +当前实现采用扁平字段 `evidence_required`,避免轻量 YAML 解析器在 case 文件里承载嵌套结构。 +`bin/lbs validate` 会校验 `priority`、`risk`、`ci_eligible`、`evidence_required`、 +`automation` 脚本路径、case 关联 skill 和 troubleshooting 交叉引用。`bin/lbs case list` +支持 `--json`、`--type`、`--area`、`--tag`、`--priority`、`--risk`、`--automation`、`--ci` +、`--ready` 和 `--machine-ready` 过滤,方便 agent 快速选择测试集。 +`env_any` 和 `automation_env_any` 用于表达 URL-or-name 这类 one-of 输入,避免把可替代变量误判为全部必填。 + +当前也有 `skills//suites/*.yaml` 和 `bin/lbs suite plan `,用于组织常跑测试集, +例如 `core-smoke`、`local-agent-gate` 和 +`agent-runner-release-gate`。发布门禁使用 `agent-runner-release-preflight` +先分类配置 blockers 和 runtime env issues,再运行较重的浏览器 Debug Chat case。 +依赖 fixture 的 case 可以在浏览器执行前先跑 `bin/lbs fixture check`,检查 +`fixtures/fixtures.json` 登记的 deterministic 文件、plugin 包和本地测试 server 是否存在。 +`bin/lbs suite start ` 会生成 suite run id、suite evidence root、per-case evidence 目录、 +`suite-start.json`/`suite-start.md` handoff 文件和 per-case evidence 命令; +浏览器自动化脚本会写入 `automation-result.json`,供 `bin/lbs test report` 展示原始自动化结论; +`bin/lbs test result ` 会在人工/agent browser case 完成后写入最终 `result.json`; +`bin/lbs suite report --evidence-dir ` 会聚合各 case 的 `result.json`,并且 +不会把缺少 required evidence 的 `pass` 当作 suite 通过。 +Runner 专用 Debug Chat case 通过 `automation_pipeline_url_env` 和 +`automation_pipeline_name_env` 绑定专用 pipeline 变量,避免 local-agent、Codex 或 +Claude Code case 误用通用 `LANGBOT_PIPELINE_URL` 后产生假阳性。 +Debug Chat case 还可以通过 `automation_stream_output` 固定流式或非流式发送路径。 +多模态 Debug Chat case 可以通过 `automation_image_base64_fixture` 复用 deterministic 图片 fixture。 +`test plan` 和 `suite plan` 会输出 readiness,让 agent 在执行浏览器前就看到缺失的 env、 +自动化变量、fixture,以及需要人工确认的 `manual_check` 前置条件。 + +完成标准: + +- `lbs case list` 或后续 filter 能回答“smoke 跑哪些”、“哪些适合 CI”、 + “哪些需要真实 provider credentials”。 + +### Phase 4:开发者沉淀流程 + +目标:开发者让 agent 测新 feature 后,稳定路径不会丢在聊天记录里。 + +流程: + +1. 开发者要求 agent 通过浏览器测试某个 feature。 +2. agent 先按 UI 主路径探索。 +3. agent 用 `lbs test start` 固定运行窗口,再用 `lbs test report` 写报告。 +4. 如果路径稳定,agent 新增或更新 case。 +5. 如果出现可复用故障,agent 新增或更新 troubleshooting。 +6. agent 跑 `bin/lbs validate` 和 `bin/lbs index`。 + +完成标准: + +- feature QA 的结果能进入资产库,而不是只留在一次对话里。 + +### Phase 5:选择性浏览器自动化 + +状态:已有第一版 `test run` 入口和两个 Playwright 脚本。 + +目标:只自动化少量稳定、值得重复跑的黑盒路径。 + +建议顺序: + +1. `webui-login-state` +2. `pipeline-debug-chat` +3. `local-agent-basic-debug-chat` +4. `local-agent-rag-debug-chat` +5. 一个基于 deterministic fixture 的 plugin 或 MCP smoke path + +执行策略: + +- 继续把 Computer Use 或 Playwright MCP 作为默认交互路径; +- 只给稳定、确定性的路径补直接 Playwright script; +- 保存 screenshots、console logs、trace/video; +- flaky 或强依赖真实 credentials 的 provider case 暂时不要进 CI。 + +当前已经绑定: + +- `webui-login-state` -> `scripts/e2e/webui-login-state.mjs` +- `pipeline-debug-chat` -> `scripts/e2e/pipeline-debug-chat.mjs` + +第一版自动化先产出 `reports/evidence//` 下的 console、network、screenshot 和 +result JSON。真实执行后仍要用 `lbs test report --since ... --console-log ...` 做日志守卫和 +最终报告。开发期间可以先用 `bin/lbs test run --dry-run` 检查命令和 evidence 路径。 +Debug Chat 类脚本应复用 `scripts/e2e/lib/debug-chat.mjs`,避免重复实现 visible response leaf +判断和已知失败信号分类。 + +完成标准: + +- 小规模 smoke subset 可以不靠人工决定每一步点击;更大的资产库仍然服务于人工/agent + 驱动的探索式 E2E。 + +## 下一批动工切片 + +在做 browser runner 之前,继续做这些: + +1. 等 LangBot 当前开发状态稳定后,用一次真实 `pipeline-debug-chat` 跑通 + `test start -> test run -> test report -> test result -> suite report`,产出 sample report。 +2. 只给 smoke/local-agent 首批 case 补必要元数据。 +3. 继续补日志守卫规则,尤其是 WebSocket、plugin runtime、provider streaming、前端 + chunk/rendering failure。 +4. 约定 report 产物目录、截图和 console/network 导出的命名方式。 +5. 再评估是否开始给 `webui-login-state` 和 `pipeline-debug-chat` 做直接 Playwright + 自动化。 + +这样 infra 会立刻有用,同时保留后续自动化 browser execution 的空间。 diff --git a/skills/qa-agent-docs/qa-agent/README.md b/skills/qa-agent-docs/qa-agent/README.md new file mode 100644 index 0000000..e844345 --- /dev/null +++ b/skills/qa-agent-docs/qa-agent/README.md @@ -0,0 +1,46 @@ +# LangBot QA Agent 文档导航 + +这个目录记录 `langbot-skills` 当前的 QA 方向和后续建设顺序。 + +## 当前判断 + +当前重点是 LangBot 的黑盒 E2E QA,不是 LangBot core 的单测覆盖率建设。 + +`langbot-skills` 要帮助开发者和 QA agent 做接近人工测试的 WebUI 验证: + +- 打开真实 LangBot WebUI; +- 按用户路径点击和输入; +- 检查用户可见的 UI 结果; +- 查看 console、network、截图、后端和前端日志; +- 输出可复用的测试报告; +- 把稳定 feature 路径沉淀为 case; +- 把重复故障沉淀为 troubleshooting。 + +API 和 curl 只做诊断。它们可以解释失败原因,但不能让一个 UI case 通过。 + +## 文档状态 + +| 文档 | 状态 | 用途 | +| --- | --- | --- | +| `04-black-box-e2e-roadmap.md` | 当前主路线图 | 决定下一步建设什么。 | +| `03-agent-browser-qa-principles.md` | 当前原则文档 | 定义 browser-first QA 的通过标准。 | +| `02-log-guard-plan.md` | 当前活跃设计 | 设计 `lbs test report` 里的日志守卫。 | +| `../user-guide.md` | 当前使用手册 | 开发者日常使用。 | +| `00-technology-options.md` | 背景文档 | 选择 Computer Use、Playwright MCP 或未来直接 Playwright。 | +| `01-qa-agent-harness-plan.md` | 历史规划,部分过时 | 解释最初分层和目录设计;使用前先看状态说明。 | + +## 已过时的点 + +`01-qa-agent-harness-plan.md` 还保留早期规划状态。现在结构化 cases、 +结构化 troubleshooting、`validate`、`index`、`lbs test plan` 都已经落地。 + +已经补上第一版 `lbs test start`、`lbs test run`、`lbs test report` 和日志守卫文件扫描。 +`webui-login-state`、`pipeline-debug-chat` 已经绑定直接 Playwright 自动化脚本。后续重点是: + +- 报告 evidence 字段继续打磨; +- case success/failure signal 和日志守卫规则继续补充; +- 报告产物和 evidence 约定; +- 等 LangBot 当前开发状态稳定后跑真实 sample report。 + +不要再把旧阶段列表当成当前 source of truth。后续排序以 +`04-black-box-e2e-roadmap.md` 为准。 diff --git a/skills/qa-agent-docs/user-guide.md b/skills/qa-agent-docs/user-guide.md new file mode 100644 index 0000000..f19beeb --- /dev/null +++ b/skills/qa-agent-docs/user-guide.md @@ -0,0 +1,521 @@ +# LangBot Skills 用户使用手册 + +## 这个仓库解决什么 + +`langbot-skills` 是给 agent 使用的 LangBot 测试资产库。开发者 clone 后,可以让 Codex、Claude Code、Computer Use 或 Playwright MCP 复用已有环境配置、测试路径和故障知识,像 QA 一样操作 LangBot WebUI。 + +核心目标: + +- 不让下一个 agent 窗口从头探索登录、模型配置、pipeline 调试。 +- 把稳定 UI 测试路径沉淀为 case。 +- 把常见故障沉淀为 troubleshooting。 +- 让 agent 优先通过浏览器点击验证产品行为。 +- API/curl/log 只作为诊断手段,不作为 UI case 通过标准。 + +## 快速开始 + +1. Clone 仓库。 + +2. 检查本地默认变量: + +```bash +bin/lbs env show +``` + +默认变量在: + +```text +skills/.env +``` + +本机专用覆盖写到: + +```text +skills/.env.local +``` + +它会覆盖 `skills/.env` 中的同名变量,并且不应该提交。 +`skills/.env` 是共享默认值,不应写入本机绝对路径、浏览器 profile、provider key 或其他凭据。 +新机器建议从模板开始: + +```bash +cp skills/.env.example skills/.env.local +``` + +常用变量: + +```text +LANGBOT_FRONTEND_URL +LANGBOT_BACKEND_URL +LANGBOT_DEV_FRONTEND_URL +LANGBOT_REPO +LANGBOT_WEB_REPO +LANGBOT_BROWSER_PROFILE +``` + +3. 检查环境是否就绪: + +```bash +bin/lbs env doctor +bin/lbs fixture check +``` + +`env doctor` 会检查 URL、路径、代理变量等。代理变量是可选项;只有大小写代理变量互相冲突时才会报错。失败不一定代表仓库坏了,通常说明本地 LangBot 没启动、代理不一致或浏览器 profile 不存在。 +`fixture check` 会检查仓库内测试 fixture 是否存在,例如 MCP stdio server、RAG 文档、多模态图片、qa-plugin-smoke 包和 QA AgentRunner 包。它也会校验 `.lbpkg` 是 zip 包,并检查 QA AgentRunner fixture 的入口文件未漂移。 + +4. 查看已有测试 case: + +```bash +bin/lbs case list +bin/lbs case list --json --priority p0 --automation +bin/lbs case list --ready +bin/lbs case list --machine-ready +bin/lbs suite list +bin/lbs suite plan core-smoke +bin/lbs suite plan agent-runner-release-gate +bin/lbs suite start core-smoke +bin/lbs suite start core-smoke --run-id core-smoke-local --evidence-dir reports/evidence/core-smoke-local +``` + +`case list` 支持按 `--type`、`--area`、`--tag`、`--priority`、`--risk`、`--automation` +、`--ci`、`--ready` 和 `--machine-ready` 过滤。`--ready` 只显示没有缺机器输入且没有人工前置条件的 case; +`--machine-ready` 过滤掉缺机器输入的 case,但保留 `manual-check`,表示执行前还要确认前置条件。需要交给 agent 自动选择测试集时,优先使用 `--json`, +其中包含 `priority`、`risk`、`ci_eligible`、`automation`、`evidence_required` 以及 +env/automation/fixture/manual readiness。 +Case metadata 中的 `env` 和 `automation_env` 表示全部必填;URL 或 name 这类二选一输入会放在 +`env_any` 或 `automation_env_any`,readiness 只要求组合里至少一个变量有值。 + +如果要跑一组已沉淀的测试路径,优先使用 suite。Suite 位于 `skills//suites/*.yaml`, +只负责组织 case,不改变 UI/browser 作为通过标准的原则。 +`suite plan` 会聚合 readiness:缺环境变量、缺自动化变量、缺 fixture 或需要 +`manual_check` 时,会在执行前标出受影响的 case。`manual_check` 不是产品通过, +它表示机器配置已满足但 agent 必须先确认 case 里的 `preconditions` 或 `setup`。 +Runner externalization 发布判断使用 `agent-runner-release-gate`。先跑 +`agent-runner-release-preflight`,把缺 pipeline、runner id 错误、插件未安装这类 +`blocked`,以及 provider key、Box、插件运行时这类 `env_issue` 分开,再执行较重的 +浏览器 Debug Chat case。 + +5. 生成 agent 执行计划: + +```bash +bin/lbs test plan pipeline-debug-chat +``` + +然后把计划交给当前 agent 执行。agent 应使用 Computer Use、Playwright MCP 或其他浏览器控制能力去操作 UI。 +`test plan` 中的 Environment、Automation Readiness、Fixture Readiness 和 Manual +Readiness 是执行前门禁;如果 readiness 缺失,应先补配置或将本次 case 标记为 +`blocked`。如果状态是 `manual_check`,先确认 `preconditions` 和 `setup`,再开始 UI +执行。不要把后续 curl/API 诊断当成 UI case 通过。 + +## 推荐使用方式 + +### 冒烟测试 + +你可以直接对 agent 说: + +```text +帮我跑一下 LangBot 新前端 smoke test。 +``` + +agent 应该: + +- 读 `skills/.env` +- 优先查看 `bin/lbs suite plan core-smoke`,或查找 `type: smoke` 的 cases +- 生成 test plan +- 用浏览器执行 UI 操作 +- 检查 console、截图、后端日志 +- 输出简短 QA 报告 + +### Runner Externalization 发布门禁 + +你可以直接对 agent 说: + +```text +按 agent-runner release gate 跑完整矩阵,先做 preflight,再跑浏览器 case,并把 blocked/env_issue/fail 分开。 +``` + +agent 应该先查看 `skills/langbot-testing/references/agent-runner-release-gate.md`, +再执行: + +```bash +bin/lbs test recommend +bin/lbs suite plan agent-runner-release-gate +bin/lbs test run agent-runner-release-preflight --dry-run +bin/lbs suite start agent-runner-release-gate --run-id agent-runner-release-local --evidence-dir reports/evidence/agent-runner-release-local +``` + +`test recommend` 输出的 run 命令默认带 `--dry-run`;确认 readiness 和 `manual_check` 前置条件后,再去掉 `--dry-run` 执行。 + +完成所有 case 后,用: + +```bash +bin/lbs suite report agent-runner-release-gate --evidence-dir reports/evidence/agent-runner-release-local +``` + +没有最终 `result.json`、缺 required evidence、或把 `blocked`/`env_issue` 当成 pass, +都不能算发布门禁通过。 + +### 新 Feature 测试 + +你可以说: + +```text +我改了 provider 页面,帮我测一下 DeepSeek provider 添加、测试、绑定 pipeline 是否正常。 +``` + +agent 应该: + +- 查找相关 case 和 reference +- 如果没有稳定路径,先探索 UI +- 用浏览器执行真实交互 +- 失败时用日志/API 辅助定位 +- 稳定后新增或更新 case/reference +- 新故障沉淀为 troubleshooting + +### 定点排错 + +你可以说: + +```text +Debug Chat 点了没回复,帮我查是前端问题还是后端问题。 +``` + +agent 应该: + +- 先通过 UI 复现 +- 看 console/network +- 看后端日志 +- 必要时用 API/curl 做诊断 +- 匹配 troubleshooting +- 给出修复建议或直接修复 + +## 重要原则 + +这些原则固定在: + +```text +docs/qa-agent/03-agent-browser-qa-principles.md +``` + +简化版: + +- UI/browser 是测试主路径。 +- API/curl/log 只做诊断。 +- 后端接口成功不等于 UI case 通过。 +- case 通过必须以用户可见 UI 结果为准。 +- 有视觉能力时应检查截图。 +- 没有视觉能力时用 DOM/accessibility snapshot 和 console。 +- 不要打印 token、API key、OAuth secret 或 localStorage token 值。 + +## 规划文档 + +如果要判断下一步建设什么,先看: + +```text +docs/qa-agent/README.md +docs/qa-agent/04-black-box-e2e-roadmap.md +``` + +`01-qa-agent-harness-plan.md` 是早期规划,部分内容已经被当前实现和路线图替代。 + +## 常用命令 + +### 环境 + +```bash +bin/lbs env show +bin/lbs env show --json +bin/lbs env doctor +bin/lbs fixture list +bin/lbs fixture check +bin/lbs fixture check --json +``` + +`env show` 和 `env doctor` 默认会对 token、API key、password、secret 以及 URL basic auth +做脱敏。不要把 `.env.local` 里的原始凭据复制进测试报告。 + +### Skill 和索引 + +```bash +bin/lbs list +bin/lbs validate +bin/lbs index --check +bin/lbs index +``` + +### Case + +```bash +bin/lbs case list +bin/lbs case list --type smoke +bin/lbs case list --json --priority p1 --tag local-agent +bin/lbs case list --ready +bin/lbs case list --machine-ready +bin/lbs case show pipeline-debug-chat +bin/lbs case new my-feature --title "My Feature Works" +``` + +### Suite + +```bash +bin/lbs suite list +bin/lbs suite list --json --priority p1 +bin/lbs suite show local-agent-gate +bin/lbs suite plan core-smoke +bin/lbs suite plan local-agent-gate --json +bin/lbs suite start core-smoke +bin/lbs suite start core-smoke --run-id core-smoke-local --evidence-dir reports/evidence/core-smoke-local +bin/lbs suite run core-smoke --dry-run --json +bin/lbs suite run core-smoke --run-id core-smoke-local --evidence-dir reports/evidence/core-smoke-local +bin/lbs suite start core-smoke --json +bin/lbs suite report core-smoke --evidence-dir reports/evidence/ +bin/lbs suite report core-smoke --evidence-dir reports/evidence/ --json +bin/lbs suite new my-feature-gate --title "My Feature Gate" +``` + +`suite start` 不直接控制浏览器。它生成统一 run id、suite evidence root、每个 case 的 evidence +目录、`suite-start.json`/`suite-start.md` handoff 文件,以及每个 case 的 `test run`、`test report` +和 `test result` 命令模板。需要固定路径时,使用 `--run-id` 和 `--evidence-dir`。 +`suite run --dry-run --json` 只预览 planned/skipped case,不创建 evidence,也不执行 automation。 +`suite run` 会顺序执行 suite 中已有 automation、机器 readiness 已满足且不需要 `manual_check` 的 case,并在最后聚合 `suite report`。 +缺 env、automation env 或 fixture 的 case 默认会跳过;确实要强制执行时,加 `--include-not-ready`。 +确认前置条件后,才用 `--include-manual-check` 执行这类 case。 +所有 case 执行完并写入最终 `result.json` 后, +`suite report` 会读取各 case evidence 目录并汇总为 `pass`、`fail`、`blocked`、`env_issue`、 +`flaky`、`incomplete` 等状态。`pass` 必须声明已经收集 case 的全部 required evidence; +否则 suite 会保持 `incomplete`,避免把缺证据的运行误判成通过。 + +### Test Plan + +```bash +bin/lbs test plan pipeline-debug-chat +bin/lbs test plan pipeline-debug-chat --json +``` + +### Test Start + +```bash +bin/lbs test start pipeline-debug-chat +bin/lbs test start pipeline-debug-chat --json +``` + +`test start` 用于 agent 开始一次浏览器测试前记录 run id、开始时间和推荐 report 命令。 +它会把 `--since ""` 写进后续报告命令,减少历史日志污染本次判断。 +如果 case 绑定了自动化脚本,输出里也会包含 `test run` 命令和 evidence 目录。 + +### Test Automation + +```bash +bin/lbs test run webui-login-state --dry-run +bin/lbs test run pipeline-debug-chat --dry-run +bin/lbs test run webui-login-state --run-id login-smoke --output reports/evidence/login-smoke +bin/lbs test run pipeline-debug-chat --run-id pipeline-smoke --output reports/evidence/pipeline-smoke +``` + +查看当前所有带自动化脚本的 case: + +```bash +bin/lbs case list --automation +bin/lbs case list --json --automation +``` + +当前自动化覆盖包括登录态、通用 Pipeline Debug Chat、local-agent runner 的基础回复、 +PromptPreProcessing、RAG、plugin tool、MCP stdio tool、非流式、多模态和 RAG+多模态路径。 +不要在文档里手工维护静态 case 清单;以 `case list --automation` 和 suite 定义为准。 + +自动化脚本位于 `scripts/e2e/`。它们会保存: + +- `console.log` +- `network.log` +- `screenshot.png` +- `automation-result.json` + +新增 Debug Chat 类自动化时,优先复用 `scripts/e2e/lib/debug-chat.mjs` 中的 pipeline 打开、 +prompt 发送、visible response leaf 判断和失败信号分类,不要在新脚本里复制 DOM 扫描逻辑。 + +脚本需要本地安装 Playwright 后才能真正执行: + +```bash +npm install +npx playwright install chromium +``` + +`pipeline-debug-chat` 通用自动化建议配置 `LANGBOT_PIPELINE_URL`。如果没有 direct URL, +脚本会尝试通过 `LANGBOT_PIPELINE_NAME` 从 Pipelines 页面进入目标 pipeline。两者都没有时, +该自动化会返回 `blocked`,不会伪造通过。 + +Runner 专用 case 不应复用通用 pipeline 变量。Local Agent、Codex AgentRunner 和 +Claude Code AgentRunner 这类 case 会通过 `automation_pipeline_url_env` / +`automation_pipeline_name_env` 映射到 case-specific env,例如 +`LANGBOT_LOCAL_AGENT_PIPELINE_URL`。这些 case 如果缺少专用变量,会返回 `blocked`, +不会退回到 `LANGBOT_PIPELINE_URL`,避免跑错 pipeline 后产生假阳性。 +如果 case 声明了 `setup_automation`,只有 `bin/lbs test run ` 的真实执行路径会先运行这些 setup; +`test plan`、`suite plan`、`case list` 和 `--dry-run` 只展示它们,不会修改本地环境。 +setup 可以是 `case:` 或仓库内 `node:scripts/... --flag`,每一步证据会写到主 evidence 目录下的 +`setup/` 子目录。setup 失败时主 automation 不会继续执行;setup 写入 `.env.local` 后,主 automation +会重新读取环境。用 `setup_provides_env` 声明 setup 会生成的变量,可以让 readiness 正确显示机器可准备状态。 +如果 Debug Chat case 需要固定流式/非流式路径,可以在 case 中设置 +`automation_stream_output: "1"` 或 `"0"`,脚本会在发送消息前切换 Debug Chat 的 Stream 控件。 +如果 case 需要上传图片,可以设置 `automation_image_base64_fixture` 指向仓库内的 base64 PNG fixture, +脚本会在 evidence 目录写出临时 PNG 并通过 Debug Chat 上传控件发送。 +`bin/lbs test plan --json` 和 `bin/lbs suite plan --json` +都会显示这些专用变量是否已配置。 + +### Test Report 和日志守卫 + +```bash +bin/lbs test report pipeline-debug-chat +bin/lbs test report pipeline-debug-chat --output reports/pipeline-debug-chat.md +bin/lbs test report pipeline-debug-chat \ + --backend-log /path/to/backend.log \ + --frontend-log /path/to/frontend.log \ + --console-log /path/to/console.log +bin/lbs test report pipeline-debug-chat --evidence-dir reports/evidence/pipeline-smoke +bin/lbs test report pipeline-debug-chat --backend-log /path/to/backend.log --json +bin/lbs test report pipeline-debug-chat --since "2026-05-21T10:30:00+08:00" +bin/lbs test report pipeline-debug-chat --tail-lines 2000 +bin/lbs test report pipeline-debug-chat --since "2026-05-21T10:30:00+08:00" --tail-lines 2000 +``` + +`test report` 会生成报告模板,并默认从 `LANGBOT_REPO/data/logs/` 自动选择最新的 +`langbot-*.log` 作为 LangBot 后端日志扫描。也可以用 `--backend-log` 覆盖,或用 +`--no-auto-log` 只生成模板。 + +如果提供 `--evidence-dir`,或 `--console-log` 指向 `reports/evidence//console.log`, +报告会优先读取同目录的 `automation-result.json`,并展示自动化脚本的 `status`、`reason`、 +起止时间和目标 URL。 + +日志守卫会扫描常见错误、secret 泄露风险、case 声明的 success/failure patterns,以及已知 +troubleshooting pattern。它不控制浏览器,也不替代 UI 通过判断。`success_patterns` +命中会作为通过证据写入 `success_signals`;声明了 success pattern 但本次扫描窗口没有命中, +会给 warning;`failure_patterns` 命中会让本次日志守卫 fail。 + +建议在执行浏览器 case 前记录当前时间,然后在报告阶段使用 `--since`。如果只想快速看 +最近日志,可以使用 `--tail-lines`。 + +### Runtime Log Guard + +如果还没有进入某个具体 UI case,只是想观察 LangBot 后端日志,可以直接使用 `log` +命令。它和 `test report` 使用同一套扫描器、secret 脱敏、troubleshooting pattern 和 +case success/failure pattern。 + +```bash +bin/lbs log scan --tail-lines 300 +bin/lbs log scan --case pipeline-debug-chat --since "2026-05-21T10:30:00+08:00" +bin/lbs log scan --backend-log /path/to/langbot.log --json +bin/lbs log scan --failure-pattern "runner.tool_loop_error|Action invoke_llm_stream call timed out" --strict +``` + +`log scan` 默认从 `LANGBOT_REPO/data/logs/` 自动选择最新的 `langbot-*.log`。传入 +`--case ` 后,会额外应用该 case 声明的 `success_patterns`、`failure_patterns` +和 related troubleshooting。默认用于观察,返回码保持 0;加 `--strict` 后,`fail` 或 +`env_issue` 会返回非 0,适合脚本门禁。 + +运行期观察可以用 `watch`: + +```bash +bin/lbs log watch --case pipeline-debug-chat +bin/lbs log watch --backend-log /path/to/langbot.log --interval-ms 500 +bin/lbs log watch --duration-ms 30000 --strict --json +``` + +`log watch` 默认从启动时的文件末尾开始,只观察新追加的日志;加 `--from-start` 可从文件开头扫。 +它会实时打印新命中的 findings 和 success signals。为了避免当前历史日志噪声影响观察,默认不因 +异常返回非 0;加 `--strict` 后,退出时如果看到 `fail` 或 `env_issue` 会返回非 0。 + +给一次 QA 运行包日志窗口时,用 `guard start/stop`: + +```bash +bin/lbs log guard start --run-id local-debug --case pipeline-debug-chat +# 执行浏览器或手工测试 +bin/lbs log guard stop --run-id local-debug +``` + +`start` 会在 `reports/log-guards/.json` 记录起始时间、case 和当前后端日志路径; +`stop` 会用 start/stop 时间作为扫描窗口,生成 `reports/log-guards/.md`,并默认按 +strict guard 返回码处理。临时只想收集报告、不想让命令失败,可以加 `--no-strict`。 + +当前 LangBot core 日志还不是完全结构化日志,runtime guard 主要依赖时间窗口和文本 pattern。 +已支持 ISO 时间戳和 LangBot 当前的 `[MM-DD HH:mm:ss.SSS]` 前缀;没有时间戳的连续行会跟随上一条 +带时间戳的日志块。如果后续 core 能提供稳定 request id、conversation id、plugin action id 或 +JSON log field,guard 可以从“时间窗口 + 文本匹配”升级为更精确的关联分析。 + +### Test Result + +```bash +bin/lbs test result pipeline-debug-chat \ + --result pass \ + --reason "Debug Chat returned OK and the report log guard was clean." \ + --evidence-dir reports/evidence/pipeline-smoke \ + --started-at "2026-05-21T10:30:00+08:00" \ + --evidence ui,screenshot,console,backend_log +``` + +`test result` 用于把一次人工/agent browser 运行的最终判断写成标准 `result.json`, +供 `suite report` 聚合。它不会替代 UI 测试:如果写 `--result pass`,`--evidence` +必须覆盖该 case 的 `evidence_required`,否则命令会失败。自动化脚本写 +`automation-result.json`;如果 case 还要求 backend log、API diagnostic 或 filesystem +evidence,agent 需要在报告和诊断完成后再用 `test result` 写最终 `result.json`。 + +### Troubleshooting + +```bash +bin/lbs trouble list langbot-testing +bin/lbs trouble show plugin-runtime-timeout +bin/lbs trouble search runtime +bin/lbs trouble add langbot-testing --title "..." --symptom "..." --cause "..." --fix "..." +``` + +## 目录说明 + +```text +skills/ + .env # 共享默认变量 + langbot-env-setup/ # 环境、浏览器、登录态、代理 + langbot-testing/ # WebUI / provider / pipeline 测试 +schemas/ # 结构化资产 schema +src/ # lbs TypeScript 源码 +bin/ # lbs 入口 +docs/ # 设计文档和用户手册 +AGENTS.md # agent 维护协议 +``` + +## 添加一个新测试路径 + +1. 先让 agent 通过浏览器探索并执行路径。 +2. 稳定后创建 case: + +```bash +bin/lbs case new provider-xxx --title "Provider XXX can be configured" --area provider --type provider +``` + +3. 编辑生成的 `cases/*.yaml`,补充真实步骤、检查点和 troubleshooting。 + +4. 校验: + +```bash +bin/lbs validate +bin/lbs index --check +bin/lbs index +``` + +## 添加一个新故障 + +```bash +bin/lbs trouble add langbot-testing \ + --title "Plugin runtime actions time out" \ + --symptom "Debug Chat shows Agent runner temporarily unavailable" \ + --cause "Old plugin runtime survived backend restart" \ + --fix "Stop old runtime processes and restart LangBot" +``` + +然后编辑生成的 YAML,补充 `patterns`、`related_cases` 和验证方式。 + +## 当前边界 + +- `lbs test plan` 只生成测试计划,不直接控制浏览器。 +- `lbs test report` 生成报告,默认扫描最新 LangBot 后端日志;也可扫描显式提供的 + backend/frontend/console 日志文件。 +- 真正的 UI 操作由当前 agent 的浏览器能力执行。 +- `env doctor` 是 readiness check,不是产品测试。 +- `curl/API` 是诊断工具,不是主要测试路径。 diff --git a/skills/schemas/README.md b/skills/schemas/README.md new file mode 100644 index 0000000..0b06d9e --- /dev/null +++ b/skills/schemas/README.md @@ -0,0 +1,59 @@ +# Schemas + +这个目录存放 LangBot skills 结构化资产的 JSON Schema。 + +它们不是测试脚本,也不会执行浏览器动作。它们的作用是定义 agent 和维护者后续新增资产时应该遵守的文件结构。 + +## 文件说明 + +- `skills//fixtures/fixtures.json` + 不是 JSON Schema,但由 `bin/lbs validate` 校验。 + 它登记 deterministic fixture 文件、类型和关联 case,供 `bin/lbs fixture check` 做 readiness 检查。 + +- `case.schema.json` + 约束 `skills//cases/*.yaml` 的格式。 + Case 描述 agent-browser 或 probe QA 路径,包括前置条件、步骤、检查点、诊断手段和关联故障。 + +- `suite.schema.json` + 约束 `skills//suites/*.yaml` 的格式。 + Suite 只组织 case 集合,用于 smoke、regression 或 release gate 等测试入口。 + +- `troubleshooting.schema.json` + 约束 `skills//troubleshooting/*.yaml` 的格式。 + Troubleshooting 条目描述症状、日志/错误模式、可能原因、修复步骤和验证信号。 + +- `skill-index.schema.json` + 约束生成文件 `skills.index.json` 的格式。 + 这个索引用于让 agent 快速发现已有 skills、references、cases、suites 和 troubleshooting。 + +- `reports/evidence//result.json` + 不是 catalog schema,而是执行期最终裁定产物,由 `bin/lbs test result` 写入。 + `suite report` 读取其中的 `status`、`reason`、起止时间和 `evidence_collected`, + 并用 `evidence_missing` 防止缺证据的 `pass` 被当作完整通过。 + +- `reports/evidence//automation-result.json` + 不是 catalog schema,而是浏览器自动化脚本的原始运行结论,供 `bin/lbs test report` + 展示和推断日志扫描窗口。 + +## 为什么需要 schemas + +Schemas 是基础设施护栏: + +- 防止 case、suite 和 troubleshooting 随着增长变得格式混乱 +- 让 `bin/lbs validate` 能发现缺字段和错误结构 +- 为未来编辑器提示和 CI 校验留接口 +- 帮助 agent 新增资产时知道应该写哪些字段 + +## 当前校验方式 + +`bin/lbs validate` 做轻量、schema 对齐的校验,不引入额外依赖。它会检查必填字段、 +枚举值、boolean 字段、重复列表项、automation 脚本存在性,以及 case、suite、skill、 +troubleshooting 之间的交叉引用。这里的 schema 仍是格式契约;如果未来引入正式 JSON +Schema validator,应继续保持这些本地交叉引用检查。 + +Case 里的 `env` / `automation_env` 表示所有列出的变量都需要配置。遇到二选一输入时, +使用 `env_any` / `automation_env_any`,每一项写成 `LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME` +这类 one-of 组合,避免 agent 因为只配置了 URL 或 name 其中之一而误判未就绪。 +`setup` 和 `preconditions` 是人工确认项,会让 readiness 进入 `manual_check`; +`setup_automation` 是 `test run` 可以自动执行的准备步骤,配合 `setup_provides_env` +声明它会生成的机器变量。 diff --git a/skills/schemas/case.schema.json b/skills/schemas/case.schema.json new file mode 100644 index 0000000..4660114 --- /dev/null +++ b/skills/schemas/case.schema.json @@ -0,0 +1,326 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://langbot.app/schemas/langbot-skills/case.schema.json", + "title": "LangBot Skill Test Case", + "type": "object", + "required": [ + "id", + "title", + "mode", + "area", + "type", + "priority", + "risk", + "ci_eligible", + "tags", + "skills", + "steps", + "checks", + "evidence_required" + ], + "allOf": [ + { + "if": { + "properties": { + "mode": { "const": "agent-browser" } + } + }, + "then": { + "required": ["env"] + } + } + ], + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "title": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": ["agent-browser", "probe"] + }, + "area": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "smoke", + "regression", + "feature", + "provider", + "exploratory", + "contract", + "performance", + "reliability", + "chaos", + "security" + ] + }, + "priority": { + "type": "string", + "enum": ["p0", "p1", "p2"] + }, + "risk": { + "type": "string", + "enum": ["low", "medium", "high"] + }, + "ci_eligible": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "skills": { + "type": "array", + "items": { "type": "string" } + }, + "env": { + "type": "array", + "items": { "type": "string" } + }, + "env_any": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*(\\|[A-Z][A-Z0-9_]*)+$" + } + }, + "steps": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "checks": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "evidence_required": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "ui", + "screenshot", + "console", + "network", + "backend_log", + "frontend_log", + "api_diagnostic", + "filesystem", + "metrics", + "trace", + "profile", + "resource_log" + ] + }, + "minItems": 1 + }, + "preconditions": { + "type": "array", + "items": { "type": "string" } + }, + "setup": { + "type": "array", + "items": { "type": "string" } + }, + "setup_automation": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?:case:[a-z0-9][a-z0-9_-]*|node:scripts/[A-Za-z0-9_./-]+\\.(?:mjs|js|ts)(?:\\s+--[A-Za-z0-9][A-Za-z0-9_-]*(?:=[A-Za-z0-9_./:@-]+)?)*)$" + } + }, + "setup_provides_env": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + } + }, + "cleanup": { + "type": "array", + "items": { "type": "string" } + }, + "diagnostics": { + "type": "array", + "items": { "type": "string" } + }, + "automation": { + "type": "string" + }, + "automation_env": { + "type": "array", + "items": { "type": "string" } + }, + "automation_env_any": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*(\\|[A-Z][A-Z0-9_]*)+$" + } + }, + "automation_prompt": { + "type": "string" + }, + "automation_prompts_json": { + "type": "string" + }, + "automation_expected_text": { + "type": "string" + }, + "automation_response_timeout_ms": { + "type": "string" + }, + "automation_stream_output": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_image_base64_fixture": { + "type": "string" + }, + "automation_runner_config_patch_json": { + "type": "string" + }, + "automation_restore_runner_config": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_expected_runner_id": { + "type": "string" + }, + "automation_reset_debug_chat": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_debug_chat_session_type": { + "type": "string", + "enum": ["person", "group"] + }, + "automation_debug_chat_response_p95_ms": { + "type": "string" + }, + "automation_debug_chat_max_error_rate": { + "type": "string" + }, + "automation_debug_chat_load_requests": { + "type": "string" + }, + "automation_debug_chat_load_concurrency": { + "type": "string" + }, + "automation_debug_chat_load_timeout_ms": { + "type": "string" + }, + "automation_debug_chat_load_response_p95_ms": { + "type": "string" + }, + "automation_debug_chat_load_first_response_p95_ms": { + "type": "string" + }, + "automation_debug_chat_load_max_error_rate": { + "type": "string" + }, + "automation_debug_chat_load_min_error_rate": { + "type": "string" + }, + "automation_debug_chat_load_min_error_count": { + "type": "string" + }, + "automation_debug_chat_load_min_ok_count": { + "type": "string" + }, + "automation_debug_chat_load_min_provider_fault_count": { + "type": "string" + }, + "automation_debug_chat_load_expected_prefix": { + "type": "string" + }, + "automation_debug_chat_load_prompt_template": { + "type": "string" + }, + "automation_debug_chat_load_stream": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_debug_chat_load_reset": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_debug_chat_load_fail_on_final_mismatch": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_fake_provider_response_text": { + "type": "string" + }, + "automation_fake_provider_first_token_delay_ms": { + "type": "string" + }, + "automation_fake_provider_chunk_delay_ms": { + "type": "string" + }, + "automation_fake_provider_chunk_count": { + "type": "string" + }, + "automation_fake_provider_fail_first_n": { + "type": "string" + }, + "automation_fake_provider_fail_every_n": { + "type": "string" + }, + "automation_fake_provider_fault_status": { + "type": "string" + }, + "automation_fake_provider_fail_after_first_chunk": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_fake_provider_dynamic_response": { + "type": "string", + "enum": ["0", "1", "false", "true"] + }, + "automation_filesystem_checks_json": { + "type": "string" + }, + "metrics_thresholds_json": { + "type": "string" + }, + "load_profile_json": { + "type": "string" + }, + "fault_model_json": { + "type": "string" + }, + "automation_pipeline_url_env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "automation_pipeline_name_env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "success_patterns": { + "type": "array", + "items": { "type": "string" } + }, + "failure_patterns": { + "type": "array", + "items": { "type": "string" } + }, + "expected_failures": { + "type": "array", + "items": { "type": "string" } + }, + "troubleshooting": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/skills/schemas/skill-index.schema.json b/skills/schemas/skill-index.schema.json new file mode 100644 index 0000000..1a080c5 --- /dev/null +++ b/skills/schemas/skill-index.schema.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://langbot.app/schemas/langbot-skills/skill-index.schema.json", + "title": "LangBot Skills Index", + "type": "object", + "required": ["generated_by", "skills"], + "additionalProperties": false, + "properties": { + "generated_by": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "object", + "required": [ + "directory", + "name", + "description", + "references", + "cases", + "case_summaries", + "suites", + "suite_summaries", + "fixtures", + "troubleshooting", + "troubleshooting_summaries" + ], + "additionalProperties": false, + "properties": { + "directory": { "type": "string" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "references": { + "type": "array", + "items": { "type": "string" } + }, + "cases": { + "type": "array", + "items": { "type": "string" } + }, + "case_summaries": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "mode", "area", "type", "priority", "risk", "ci_eligible", "tags", "automation", "setup_automation", "setup_provides_env", "evidence_required"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "mode": { "type": "string", "enum": ["agent-browser", "probe"] }, + "area": { "type": "string" }, + "type": { "type": "string" }, + "priority": { "type": "string" }, + "risk": { "type": "string" }, + "ci_eligible": { "type": "boolean" }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "automation": { "type": "string" }, + "setup_automation": { + "type": "array", + "items": { "type": "string" } + }, + "setup_provides_env": { + "type": "array", + "items": { "type": "string" } + }, + "evidence_required": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + "suites": { + "type": "array", + "items": { "type": "string" } + }, + "suite_summaries": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "description", "type", "priority", "tags", "cases"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "type": { "type": "string" }, + "priority": { "type": "string" }, + "tags": { + "type": "array", + "items": { "type": "string" } + }, + "cases": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + "fixtures": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "kind", "path", "related_cases"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "kind": { "type": "string" }, + "path": { "type": "string" }, + "related_cases": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + "troubleshooting": { + "type": "array", + "items": { "type": "string" } + }, + "troubleshooting_summaries": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "category", "related_cases"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "category": { "type": "string" }, + "related_cases": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } +} diff --git a/skills/schemas/suite.schema.json b/skills/schemas/suite.schema.json new file mode 100644 index 0000000..4f3fa7c --- /dev/null +++ b/skills/schemas/suite.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://langbot.app/schemas/langbot-skills/suite.schema.json", + "title": "LangBot Skill Test Suite", + "type": "object", + "required": ["id", "title", "description", "type", "priority", "tags", "cases"], + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "smoke", + "regression", + "release_gate", + "exploratory", + "contract", + "performance", + "reliability", + "chaos", + "security" + ] + }, + "priority": { + "type": "string", + "enum": ["p0", "p1", "p2"] + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "cases": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + } +} diff --git a/skills/schemas/troubleshooting.schema.json b/skills/schemas/troubleshooting.schema.json new file mode 100644 index 0000000..1005f5e --- /dev/null +++ b/skills/schemas/troubleshooting.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://langbot.app/schemas/langbot-skills/troubleshooting.schema.json", + "title": "LangBot Skill Troubleshooting Entry", + "type": "object", + "required": ["id", "title", "symptoms", "patterns", "likely_causes", "fix_steps", "verification"], + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "title": { + "type": "string" + }, + "date": { + "type": "string" + }, + "category": { + "type": "string", + "enum": ["product", "env_issue", "external_dependency", "blocked", "flaky"] + }, + "symptoms": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "patterns": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "likely_causes": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "fix_steps": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "verification": { + "type": "string" + }, + "related_cases": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/skills/scripts/bootstrap-lbs.mjs b/skills/scripts/bootstrap-lbs.mjs new file mode 100755 index 0000000..07233a7 --- /dev/null +++ b/skills/scripts/bootstrap-lbs.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const binDir = resolve(root, "bin"); +const lbsPath = resolve(binDir, "lbs"); +const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "", + 'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"', + 'exec node "$SCRIPT_DIR/../src/lbs.ts" "$@"', + "", +].join("\n"); + +await mkdir(binDir, { recursive: true }); + +let current = ""; +try { + current = await readFile(lbsPath, "utf8"); +} catch { + // Missing wrapper is the normal first-run path. +} + +if (current !== wrapper) { + await writeFile(lbsPath, wrapper, "utf8"); + await chmod(lbsPath, 0o755); +} diff --git a/skills/scripts/e2e/agent-runner-release-preflight.mjs b/skills/scripts/e2e/agent-runner-release-preflight.mjs new file mode 100755 index 0000000..6ac69f2 --- /dev/null +++ b/skills/scripts/e2e/agent-runner-release-preflight.mjs @@ -0,0 +1,476 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + localIsoWithOffset, + safeScreenshot, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +function loadEnvDefaults(path) { + if (!existsSync(path)) return; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf("="); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + if (env[key]) continue; + env[key] = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + } +} + +function boolFromEnv(value, defaultValue) { + if (value === undefined || value === "") return defaultValue; + if (/^(0|false|no|off)$/i.test(value)) return false; + if (/^(1|true|yes|on)$/i.test(value)) return true; + return defaultValue; +} + +function firstEnv(...keys) { + for (const key of keys) { + if (env[key]) return env[key]; + } + return ""; +} + +function redactMessage(text) { + return String(text ?? "") + .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]") + .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]") + .replace(/(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?[^"',\s]+/gi, "$1=[redacted]"); +} + +function isEnvironmentError(message) { + return /Playwright is not installed|LANGBOT_FRONTEND_URL|LANGBOT_BACKEND_URL|ERR_CONNECTION_REFUSED|ECONNREFUSED|net::ERR_|fetch failed|timed out/i + .test(message); +} + +loadEnvDefaults("skills/.env"); +loadEnvDefaults("skills/.env.local"); + +const caseId = env.LBS_CASE_ID || "agent-runner-release-preflight"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const frontendUrl = env.LANGBOT_FRONTEND_URL || backendUrl; +const testModels = boolFromEnv(env.LANGBOT_PREFLIGHT_TEST_MODELS, true); +const requireVision = boolFromEnv(env.LANGBOT_PREFLIGHT_REQUIRE_VISION, true); +const diagnosticPath = resolve(paths.evidenceDir, "api-diagnostic.json"); +const startedAt = new Date(); + +const targets = [ + { + id: "local-agent", + expected_runner_id: "plugin:langbot/local-agent/default", + pipeline_url: firstEnv("LANGBOT_LOCAL_AGENT_PIPELINE_URL"), + pipeline_name: firstEnv("LANGBOT_LOCAL_AGENT_PIPELINE_NAME"), + require_func_call_model: true, + require_vision_model: requireVision, + require_langbot_mcp: false, + }, + { + id: "acp-agent-runner", + expected_runner_id: "plugin:langbot/acp-agent-runner/default", + pipeline_url: firstEnv("LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", "LANGBOT_AGENT_RUNNER_PIPELINE_URL"), + pipeline_name: firstEnv("LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME", "LANGBOT_AGENT_RUNNER_PIPELINE_NAME"), + require_func_call_model: false, + require_vision_model: false, + }, +]; + +let browser; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + frontend_url: frontendUrl, + backend_url: backendUrl, + test_models: testModels, + require_vision_model: requireVision, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + api_diagnostic_json: diagnosticPath, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "network", "api_diagnostic"], +}; + +async function run() { + if (!backendUrl || !frontendUrl) { + result.status = "env_issue"; + result.reason = "LANGBOT_FRONTEND_URL and LANGBOT_BACKEND_URL must be configured."; + return; + } + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + + const diagnostic = await page.evaluate(async ({ backendUrl, targets, testModels }) => { + const blockers = []; + const envIssues = []; + const warnings = []; + const checks = []; + + const addCheck = (name, status, detail = {}) => { + checks.push({ name, status, ...detail }); + if (status === "blocked") blockers.push({ name, ...detail }); + if (status === "env_issue") envIssues.push({ name, ...detail }); + }; + const safeMessage = (value) => String(value ?? "") + .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]") + .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]") + .replace(/(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?[^"',\s]+/gi, "$1=[redacted]"); + + const token = localStorage.getItem("token"); + if (!token) { + addCheck("browser-auth", "blocked", { reason: "Browser profile has no localStorage token." }); + return { authenticated: false, blockers, env_issues: envIssues, warnings, checks }; + } + + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + const getJson = async (path) => { + const response = await fetch(`${backendUrl}${path}`, { headers }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + const postJson = async (path, body) => { + const response = await fetch(`${backendUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + + const tokenCheck = await getJson("/api/v1/user/check-token"); + addCheck( + "browser-auth", + tokenCheck.status < 400 && (tokenCheck.json.code ?? 0) === 0 ? "pass" : "blocked", + { http_status: tokenCheck.status, code: tokenCheck.json.code ?? null, reason: safeMessage(tokenCheck.json.msg || "") }, + ); + + const systemInfo = await getJson("/api/v1/system/info"); + addCheck( + "backend-system-info", + systemInfo.status < 400 ? "pass" : "env_issue", + { + http_status: systemInfo.status, + version: systemInfo.json.data?.version || systemInfo.json.data?.system?.version || "", + }, + ); + + const pluginSystem = await getJson("/api/v1/system/status/plugin-system"); + addCheck( + "plugin-system", + pluginSystem.status < 400 && (pluginSystem.json.code ?? 0) === 0 ? "pass" : "env_issue", + { + http_status: pluginSystem.status, + code: pluginSystem.json.code ?? null, + status: pluginSystem.json.data?.status || pluginSystem.json.data?.state || "", + reason: safeMessage(pluginSystem.json.msg || ""), + }, + ); + + const boxStatus = await getJson("/api/v1/box/status"); + addCheck( + "box-runtime", + boxStatus.status < 400 && (boxStatus.json.code ?? 0) === 0 ? "pass" : "env_issue", + { + http_status: boxStatus.status, + code: boxStatus.json.code ?? null, + status: boxStatus.json.data?.status || "", + backend: boxStatus.json.data?.backend || "", + reason: safeMessage(boxStatus.json.msg || ""), + }, + ); + + const plugins = await getJson("/api/v1/plugins"); + const installedPluginIds = (plugins.json.data?.plugins || []) + .map((plugin) => { + const metadata = plugin.manifest?.manifest?.metadata || plugin.manifest?.metadata || plugin.metadata || {}; + return metadata.author && metadata.name ? `${metadata.author}/${metadata.name}` : ""; + }) + .filter(Boolean); + const requiredPlugins = ["langbot/local-agent", "langbot/acp-agent-runner", "qa/plugin-smoke"]; + const pluginPresence = Object.fromEntries(requiredPlugins.map((id) => [id, installedPluginIds.includes(id)])); + for (const [id, present] of Object.entries(pluginPresence)) { + addCheck(`plugin:${id}`, present ? "pass" : "blocked", { plugin_id: id, reason: present ? "" : "Required plugin is not listed by /api/v1/plugins." }); + } + + const tools = await getJson("/api/v1/tools"); + const toolNames = (tools.json.data?.tools || []) + .map((tool) => tool.name || tool.tool_name || tool.function?.name || "") + .filter(Boolean) + .sort(); + addCheck( + "tool:qa_plugin_echo", + toolNames.includes("qa_plugin_echo") ? "pass" : "blocked", + { reason: toolNames.includes("qa_plugin_echo") ? "" : "qa-plugin-smoke tool qa_plugin_echo is not exposed through /api/v1/tools." }, + ); + if (!toolNames.includes("qa_mcp_echo")) { + warnings.push({ + name: "tool:qa_mcp_echo", + reason: "qa_mcp_echo is not currently exposed. This is acceptable before mcp-stdio-register, but mcp-stdio-tool-call must run after registration.", + }); + } + + const modelResponse = await getJson("/api/v1/provider/models/llm"); + const models = (modelResponse.json.data?.models || []).map((model) => ({ + uuid: model.uuid, + name: model.name, + abilities: Array.isArray(model.abilities) ? model.abilities : [], + provider_uuid: model.provider_uuid || model.provider?.uuid || "", + provider_name: model.provider_name || model.provider?.name || "", + requester: model.requester || model.provider?.requester || "", + })); + addCheck( + "llm-model-list", + modelResponse.status < 400 && (modelResponse.json.code ?? 0) === 0 ? "pass" : "env_issue", + { http_status: modelResponse.status, model_count: models.length, reason: safeMessage(modelResponse.json.msg || "") }, + ); + const modelById = new Map(models.map((model) => [model.uuid, model])); + + const pipelineList = await getJson("/api/v1/pipelines"); + const pipelines = pipelineList.json.data?.pipelines || []; + addCheck( + "pipeline-list", + pipelineList.status < 400 && (pipelineList.json.code ?? 0) === 0 ? "pass" : "blocked", + { http_status: pipelineList.status, pipeline_count: pipelines.length, reason: safeMessage(pipelineList.json.msg || "") }, + ); + + const resolvedPipelines = []; + const modelTested = new Set(); + for (const target of targets) { + let pipelineId = ""; + let matchedBy = ""; + if (target.pipeline_url) { + try { + pipelineId = new URL(target.pipeline_url).searchParams.get("id") || ""; + matchedBy = pipelineId ? "url" : ""; + } catch { + pipelineId = ""; + } + } + if (!pipelineId && target.pipeline_name) { + const match = pipelines.find((pipeline) => pipeline.name === target.pipeline_name); + if (match) { + pipelineId = match.uuid; + matchedBy = "name"; + } + } + if (!pipelineId) { + addCheck(`pipeline:${target.id}`, "blocked", { + target: target.id, + reason: "Required pipeline env is missing or could not resolve to a pipeline id.", + }); + continue; + } + + const response = await getJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`); + const pipeline = response.json.data?.pipeline; + if (response.status >= 400 || !pipeline) { + addCheck(`pipeline:${target.id}`, "blocked", { + target: target.id, + pipeline_id: pipelineId, + http_status: response.status, + reason: safeMessage(response.json.msg || "Could not load pipeline."), + }); + continue; + } + + const config = pipeline.config || {}; + const aiConfig = config.ai && typeof config.ai === "object" ? config.ai : {}; + const runner = aiConfig.runner && typeof aiConfig.runner === "object" ? aiConfig.runner : {}; + const runnerId = runner.id || runner.runner || ""; + const runnerConfigs = aiConfig.runner_config && typeof aiConfig.runner_config === "object" ? aiConfig.runner_config : {}; + const runnerConfig = runnerConfigs[runnerId] && typeof runnerConfigs[runnerId] === "object" ? runnerConfigs[runnerId] : {}; + const pipelineSummary = { + target: target.id, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + runner_id: runnerId, + expected_runner_id: target.expected_runner_id, + runner_config_keys: Object.keys(runnerConfig).sort(), + }; + resolvedPipelines.push(pipelineSummary); + + addCheck( + `pipeline:${target.id}:runner`, + runnerId === target.expected_runner_id ? "pass" : "blocked", + { + ...pipelineSummary, + reason: runnerId === target.expected_runner_id ? "" : `Expected ${target.expected_runner_id}, got ${runnerId || ""}.`, + }, + ); + + if (target.require_func_call_model || target.require_vision_model || (testModels && target.id === "local-agent")) { + const modelConfig = runnerConfig.model; + const primaryModelId = typeof modelConfig === "string" + ? modelConfig + : modelConfig && typeof modelConfig === "object" + ? modelConfig.primary || "" + : ""; + if (!primaryModelId) { + addCheck(`pipeline:${target.id}:primary-model`, "blocked", { + ...pipelineSummary, + reason: "Local-agent runner config has no primary model.", + }); + continue; + } + const model = modelById.get(primaryModelId); + if (!model) { + addCheck(`pipeline:${target.id}:primary-model`, "blocked", { + ...pipelineSummary, + model_uuid: primaryModelId, + reason: "Primary model is not listed by /api/v1/provider/models/llm.", + }); + continue; + } + addCheck(`pipeline:${target.id}:primary-model`, "pass", { + ...pipelineSummary, + model: { + uuid: model.uuid, + name: model.name, + abilities: model.abilities, + provider_name: model.provider_name, + requester: model.requester, + }, + }); + if (target.require_func_call_model) { + addCheck( + `pipeline:${target.id}:func-call-model`, + model.abilities.includes("func_call") ? "pass" : "env_issue", + { + model_uuid: model.uuid, + model_name: model.name, + abilities: model.abilities, + reason: model.abilities.includes("func_call") ? "" : "Release gate includes tool-call cases; the local-agent primary model must advertise func_call.", + }, + ); + } + if (target.require_vision_model) { + addCheck( + `pipeline:${target.id}:vision-model`, + model.abilities.includes("vision") ? "pass" : "env_issue", + { + model_uuid: model.uuid, + model_name: model.name, + abilities: model.abilities, + reason: model.abilities.includes("vision") ? "" : "Release gate includes multimodal cases; the local-agent primary model must advertise vision.", + }, + ); + } + if (testModels && !modelTested.has(model.uuid)) { + modelTested.add(model.uuid); + const modelTest = await postJson(`/api/v1/provider/models/llm/${encodeURIComponent(model.uuid)}/test`, { extra_args: {} }); + const passed = modelTest.status < 400 && (modelTest.json.code ?? 0) === 0; + addCheck( + `model-test:${model.name}`, + passed ? "pass" : "env_issue", + { + model_uuid: model.uuid, + model_name: model.name, + http_status: modelTest.status, + code: modelTest.json.code ?? null, + reason: passed ? "" : safeMessage(modelTest.json.msg || modelTest.json.message || "Model test failed."), + }, + ); + } + } + } + + return { + authenticated: true, + blockers, + env_issues: envIssues, + warnings, + checks, + resolved_pipelines: resolvedPipelines, + tools: { + required: ["qa_plugin_echo"], + optional_before_register: ["qa_mcp_echo"], + present: toolNames.filter((name) => ["qa_plugin_echo", "qa_mcp_echo"].includes(name)), + }, + models, + }; + }, { backendUrl, targets, testModels }); + + diagnostic.blockers = (diagnostic.blockers || []).map((item) => ({ ...item, reason: redactMessage(item.reason || "") })); + diagnostic.env_issues = (diagnostic.env_issues || []).map((item) => ({ ...item, reason: redactMessage(item.reason || "") })); + await writeFile(diagnosticPath, `${JSON.stringify(diagnostic, null, 2)}\n`, "utf8"); + await safeScreenshot(page, paths.screenshot); + + const blockers = diagnostic.blockers || []; + const envIssues = diagnostic.env_issues || []; + if (blockers.length > 0) { + result.status = "blocked"; + result.reason = `Preflight blocked: ${blockers.map((item) => item.name).join(", ")}`; + } else if (envIssues.length > 0) { + result.status = "env_issue"; + result.reason = `Preflight environment issue: ${envIssues.map((item) => item.name).join(", ")}`; + } else { + result.status = "pass"; + result.reason = "Release gate preflight passed: auth, plugin runtime, required pipelines, runner ids, tools, and local-agent model checks are ready."; + } + result.check_count = Array.isArray(diagnostic.checks) ? diagnostic.checks.length : 0; + result.warning_count = Array.isArray(diagnostic.warnings) ? diagnostic.warnings.length : 0; +} + +try { + await run(); +} catch (error) { + const message = redactMessage(error instanceof Error ? error.message : String(error)); + result.status = isEnvironmentError(message) ? "env_issue" : "fail"; + result.reason = message; + await writeFile(diagnosticPath, `${JSON.stringify({ + authenticated: false, + blockers: [], + env_issues: result.status === "env_issue" ? [{ name: "preflight-runtime", reason: message }] : [], + warnings: [], + checks: [ + { + name: "preflight-runtime", + status: result.status, + reason: message, + }, + ], + }, null, 2)}\n`, "utf8").catch(() => {}); +} finally { + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs b/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs new file mode 100755 index 0000000..1278fcd --- /dev/null +++ b/skills/scripts/e2e/ensure-acp-agent-runner-pipeline.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + ensureEvidence, + evidencePaths, + loadEnvFiles, + resetAndAuthLocalUser, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const RUNNER_ID = "plugin:langbot/acp-agent-runner/default"; +const DEFAULT_PIPELINE_NAME = "Agent QA ACP Claude Debug Chat"; +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; +const caseId = "ensure-acp-agent-runner-pipeline"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const writeEnv = process.argv.includes("--write-env"); +const frontendUrl = env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const pipelineName = env.LANGBOT_E2E_CREATE_PIPELINE_NAME || env.LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME || DEFAULT_PIPELINE_NAME; +const sshTarget = env.LANGBOT_ACP_AGENT_RUNNER_SSH_TARGET || "yhh@101.34.71.12"; +const sshConnectTimeout = env.LANGBOT_ACP_AGENT_RUNNER_SSH_CONNECT_TIMEOUT || "8"; +const sshPort = env.LANGBOT_ACP_AGENT_RUNNER_SSH_PORT || "22"; +const sshIdentityFile = env.LANGBOT_ACP_AGENT_RUNNER_SSH_IDENTITY_FILE || ""; +const sshExtraOptions = env.LANGBOT_ACP_AGENT_RUNNER_SSH_EXTRA_OPTIONS || ""; +const remoteWorkspace = env.LANGBOT_ACP_AGENT_RUNNER_REMOTE_WORKSPACE || "/home/yhh/langbot-e2e/acp-workspace"; +const envLocalPath = resolve("skills/.env.local"); + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + frontend_url: frontendUrl, + backend_url: backendUrl, + pipeline_name: pipelineName, + pipeline_id: "", + pipeline_url: "", + runner_id: RUNNER_ID, + ssh_target: sshTarget, + ssh_port: sshPort, + remote_workspace: remoteWorkspace, + wrote_env: false, + auth: null, + evidence: { + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API."); + } + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + result.auth = { + source: "local_recovery_login", + user, + backend_token_check: auth.check, + }; + + const runnerConfig = { + provider: "claude-code", + location: "remote-ssh", + workspace: remoteWorkspace, + "ssh-target": sshTarget, + "ssh-port": Number.parseInt(sshPort, 10), + "ssh-identity-file": sshIdentityFile, + "ssh-connect-timeout": Number.parseInt(sshConnectTimeout, 10), + "ssh-extra-options": sshExtraOptions, + "langbot-assets-enabled": true, + "mcp-bridge-request-timeout": 90, + "reuse-session": false, + "create-session-if-missing": true, + "append-run-scope-prompt": true, + "startup-timeout": 30, + "initialize-timeout": 120, + timeout: 300, + }; + + const prepared = await ensurePipeline({ + backendUrl, + token: auth.token, + pipelineName, + runnerId: RUNNER_ID, + runnerConfig, + }); + Object.assign(result, prepared); + if (result.pipeline_id) { + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; + } + + if (writeEnv && result.pipeline_id) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_E2E_LOGIN_USER: user, + LANGBOT_ACP_AGENT_RUNNER_SSH_TARGET: sshTarget, + LANGBOT_ACP_AGENT_RUNNER_SSH_PORT: sshPort, + LANGBOT_ACP_AGENT_RUNNER_SSH_IDENTITY_FILE: sshIdentityFile, + LANGBOT_ACP_AGENT_RUNNER_SSH_EXTRA_OPTIONS: sshExtraOptions, + LANGBOT_ACP_AGENT_RUNNER_REMOTE_WORKSPACE: remoteWorkspace, + LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL: result.pipeline_url, + LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME: result.pipeline_name || pipelineName, + }); + result.wrote_env = true; + } +} catch (error) { + result.reason = result.reason || error.message; +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runnerConfig }) { + const pipelineList = await apiJson(backendUrl, "/api/v1/pipelines", { token }); + if (isApiFailure(pipelineList)) { + return { + status: "fail", + reason: pipelineList.json.msg || "Failed to list pipelines.", + list_status: pipelineList.status, + }; + } + + const pipelines = pipelineList.json.data?.pipelines || []; + let pipeline = pipelines.find((item) => item.name === pipelineName) || null; + let created = false; + + if (!pipeline) { + const createdResponse = await apiJson(backendUrl, "/api/v1/pipelines", { + method: "POST", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for real ACP Claude AgentRunner Debug Chat smoke tests.", + emoji: "QA", + }, + }); + if (isApiFailure(createdResponse)) { + return { + status: "fail", + reason: createdResponse.json.msg || "Failed to create pipeline.", + create_status: createdResponse.status, + }; + } + const pipelineId = createdResponse.json.data?.uuid || ""; + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { token }); + pipeline = loaded.json.data?.pipeline || null; + created = true; + } + + if (!pipeline?.uuid) { + return { + status: "fail", + reason: "Pipeline was not created or resolved.", + }; + } + + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token }); + if (isApiFailure(loaded) || !loaded.json.data?.pipeline) { + return { + status: "fail", + reason: loaded.json.msg || "Failed to load pipeline.", + get_status: loaded.status, + pipeline_id: pipeline.uuid, + }; + } + pipeline = loaded.json.data.pipeline; + + const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; + const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; + const runnerConfigs = ai.runner_config && typeof ai.runner_config === "object" ? ai.runner_config : {}; + const updatedConfig = { + ...config, + ai: { + ...ai, + runner: { + ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), + id: runnerId, + "expire-time": 0, + }, + runner_config: { + ...runnerConfigs, + [runnerId]: runnerConfig, + }, + }, + }; + + const updateResponse = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { + method: "PUT", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for real ACP Claude AgentRunner Debug Chat smoke tests.", + emoji: "QA", + config: updatedConfig, + }, + }); + if (isApiFailure(updateResponse)) { + return { + status: "fail", + reason: updateResponse.json.msg || "Failed to update pipeline.", + update_status: updateResponse.status, + pipeline_id: pipeline.uuid, + }; + } + + return { + status: "pass", + reason: created ? "ACP AgentRunner pipeline created and configured." : "ACP AgentRunner pipeline updated.", + pipeline_id: pipeline.uuid, + pipeline_name: pipelineName, + created, + updated: true, + }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json && response.json.code !== undefined && response.json.code !== 0); +} + +async function upsertEnvLocal(path, values) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const keys = new Set(Object.keys(values)); + const output = []; + for (const line of lines) { + const match = line.match(/^([A-Z][A-Z0-9_]*)=/); + if (match && keys.has(match[1])) { + output.push(`${match[1]}=${values[match[1]]}`); + keys.delete(match[1]); + } else if (line !== "" || output.length > 0) { + output.push(line); + } + } + if (keys.size > 0 && output.length > 0 && output[output.length - 1] !== "") { + output.push(""); + } + for (const key of keys) { + output.push(`${key}=${values[key]}`); + } + await writeFile(path, `${output.join("\n").replace(/\n+$/, "")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/ensure-fake-provider-cross-pipelines.mjs b/skills/scripts/e2e/ensure-fake-provider-cross-pipelines.mjs new file mode 100755 index 0000000..592a7b7 --- /dev/null +++ b/skills/scripts/e2e/ensure-fake-provider-cross-pipelines.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { env } from "node:process"; +import { + appendLine, + ensureEvidence, + evidencePaths, + loadEnvFiles, + redact, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "ensure-fake-provider-cross-pipelines"; +const DEFAULT_PIPELINE_A_NAME = "LangBot QA Fake Provider Debug Chat A"; +const DEFAULT_PIPELINE_B_NAME = "LangBot QA Fake Provider Debug Chat B"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const writeEnv = process.argv.includes("--write-env"); +const envLocalPath = resolve("skills/.env.local"); +const pipelineAName = env.LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME || DEFAULT_PIPELINE_A_NAME; +const pipelineBName = env.LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME || DEFAULT_PIPELINE_B_NAME; + +const result = { + source: "setup_automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + pipeline_a: { + name: pipelineAName, + id: "", + url: "", + }, + pipeline_b: { + name: pipelineBName, + id: "", + url: "", + }, + fake_provider: { + url: "", + base_url: "", + pid: null, + }, + wrote_env: false, + evidence: { + console_log: paths.consoleLog, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic", "filesystem"], +}; + +try { + console.error(`[langbot-qa] configuring cross-pipeline QA fixtures: pipeline_a=\"${pipelineAName}\", pipeline_b=\"${pipelineBName}\"`); + console.error("[langbot-qa] run these fake-provider setup/probe commands serially when they share LANGBOT_FAKE_PROVIDER_URL."); + if (pipelineAName === pipelineBName) { + throw new Error("LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME and LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME must be different."); + } + + const setupA = await runPipelineSetup(pipelineAName, "A"); + const setupB = await runPipelineSetup(pipelineBName, "B"); + result.pipeline_a = { + name: setupA.pipeline_name || pipelineAName, + id: setupA.pipeline_id || "", + url: setupA.pipeline_url || "", + }; + result.pipeline_b = { + name: setupB.pipeline_name || pipelineBName, + id: setupB.pipeline_id || "", + url: setupB.pipeline_url || "", + }; + result.fake_provider = { + url: setupB.fake_provider?.url || setupA.fake_provider?.url || "", + base_url: setupB.fake_provider?.base_url || setupA.fake_provider?.base_url || "", + pid: setupB.fake_provider?.pid ?? setupA.fake_provider?.pid ?? null, + }; + + if (!result.pipeline_a.url || !result.pipeline_b.url || !result.fake_provider.url) { + throw new Error("Cross-pipeline fake provider setup did not return both pipeline URLs and provider URL."); + } + + if (writeEnv) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_FAKE_PROVIDER_URL: result.fake_provider.url, + LANGBOT_FAKE_PROVIDER_BASE_URL: result.fake_provider.base_url, + LANGBOT_FAKE_PROVIDER_PID: result.fake_provider.pid ? String(result.fake_provider.pid) : "", + LANGBOT_FAKE_PROVIDER_PIPELINE_A_URL: result.pipeline_a.url, + LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME: result.pipeline_a.name, + LANGBOT_FAKE_PROVIDER_PIPELINE_B_URL: result.pipeline_b.url, + LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME: result.pipeline_b.name, + }); + result.wrote_env = true; + } + + result.status = "pass"; + result.reason = "Fake provider cross-pipeline fixtures are configured."; +} catch (error) { + result.status = looksLikeEnvIssue(error) ? "env_issue" : "fail"; + result.reason = safeReason(error.message); +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +function runPipelineSetup(pipelineName, label) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(process.execPath, ["scripts/e2e/ensure-fake-provider-pipeline.mjs"], { + cwd: resolve("."), + env: { + ...env, + LANGBOT_FAKE_PROVIDER_PIPELINE_NAME: pipelineName, + LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS: env.LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS || "25", + LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS: env.LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS || "10", + LANGBOT_FAKE_PROVIDER_CHUNK_COUNT: env.LANGBOT_FAKE_PROVIDER_CHUNK_COUNT || "0", + LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N: "0", + LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N: "0", + LANGBOT_FAKE_PROVIDER_FAULT_STATUS: env.LANGBOT_FAKE_PROVIDER_FAULT_STATUS || "500", + LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK: "false", + LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + const text = chunk.toString(); + stdout += text; + appendLine(paths.consoleLog, `[setup ${label} stdout] ${text.trimEnd()}`).catch(() => {}); + }); + child.stderr.on("data", (chunk) => { + const text = chunk.toString(); + stderr += text; + appendLine(paths.consoleLog, `[setup ${label} stderr] ${text.trimEnd()}`).catch(() => {}); + }); + child.on("error", rejectPromise); + child.on("close", (code) => { + const parsed = parseJsonOutput(stdout); + if (code !== 0 || parsed.status !== "pass") { + rejectPromise(new Error(parsed.reason || stderr || `Fake provider pipeline setup ${label} exited with ${code}.`)); + return; + } + resolvePromise(parsed); + }); + }); +} + +function parseJsonOutput(text) { + const trimmed = String(text || "").trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { + return JSON.parse(trimmed.slice(start, end + 1)); + } catch { + return {}; + } + } + return {}; + } +} + +async function upsertEnvLocal(path, updates) { + await mkdir(dirname(path), { recursive: true }); + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const seen = new Set(); + const next = lines.map((line) => { + const trimmed = line.trim(); + const match = trimmed.match(/^([A-Z][A-Z0-9_]*)=/); + if (!match || updates[match[1]] === undefined) return line; + seen.add(match[1]); + return `${match[1]}=${updates[match[1]]}`; + }); + for (const [key, value] of Object.entries(updates)) { + if (!seen.has(key)) next.push(`${key}=${value}`); + } + await writeFile(path, `${next.join("\n").replace(/\n+$/, "")}\n`, "utf8"); +} + +function looksLikeEnvIssue(error) { + const message = String(error?.message || error || ""); + return /fetch failed|ECONNREFUSED|ENOTFOUND|LANGBOT_.*not configured|Could not read recovery_key|Backend did not respond/i.test(message); +} + +function safeReason(value) { + return redact(String(value || "")).slice(0, 1000); +} diff --git a/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs b/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs new file mode 100755 index 0000000..73f2465 --- /dev/null +++ b/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs @@ -0,0 +1,635 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { open, readFile, mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + ensureEvidence, + evidencePaths, + loadEnvFiles, + redact, + resetAndAuthLocalUser, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const RUNNER_ID = "local-agent"; +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; +const DEFAULT_PIPELINE_NAME = "LangBot QA Fake Provider Debug Chat"; +const DEFAULT_PROVIDER_NAME = "LangBot QA Fake OpenAI Provider"; +const QA_RESOURCE_DESCRIPTION = "Managed by LangBot skills QA automation for controlled fake-provider Debug Chat tests. Safe to delete when local QA fixtures are no longer needed."; +const DEFAULT_MODEL_NAME = "gpt-4o-mini"; +const DEFAULT_REQUESTER = "openai-chat-completions"; + +const caseId = "ensure-fake-provider-pipeline"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const writeEnv = process.argv.includes("--write-env"); +const frontendUrl = env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const envLocalPath = resolve("skills/.env.local"); +const repoRoot = resolve(env.LANGBOT_REPO || ".."); +const fakeStateDir = resolve(env.LANGBOT_FAKE_PROVIDER_STATE_DIR || resolve(repoRoot, ".qa/fake-provider")); +const fakeStatePath = resolve(fakeStateDir, "state.json"); +const fakeStdoutPath = resolve(fakeStateDir, "fake-provider.stdout.log"); +const fakeStderrPath = resolve(fakeStateDir, "fake-provider.stderr.log"); +const pipelineName = env.LANGBOT_FAKE_PROVIDER_PIPELINE_NAME || DEFAULT_PIPELINE_NAME; +const providerName = env.LANGBOT_FAKE_PROVIDER_NAME || DEFAULT_PROVIDER_NAME; +const requester = env.LANGBOT_FAKE_PROVIDER_REQUESTER || DEFAULT_REQUESTER; +const modelName = env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || DEFAULT_MODEL_NAME; + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + frontend_url: frontendUrl, + backend_url: backendUrl, + fake_provider: { + url: "", + base_url: "", + pid: null, + reused: false, + config: {}, + state_file: fakeStatePath, + stdout_log: fakeStdoutPath, + stderr_log: fakeStderrPath, + }, + provider: { + uuid: "", + name: providerName, + requester, + created: false, + updated: false, + }, + model: { + uuid: "", + name: modelName, + created: false, + updated: false, + test_status: "not_run", + test_reason: "", + }, + pipeline_id: "", + pipeline_name: pipelineName, + pipeline_url: "", + created: false, + updated: false, + wrote_env: false, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic", "network", "filesystem"], +}; + +try { + console.error(`[langbot-qa] configuring QA-owned fake-provider fixtures: provider=\"${providerName}\", pipeline=\"${pipelineName}\"`); + console.error("[langbot-qa] this setup may create or update local QA provider/model/pipeline resources on the selected backend."); + if (!backendUrl) { + result.status = "env_issue"; + throw new Error("LANGBOT_BACKEND_URL is not configured."); + } + if (!frontendUrl) { + result.status = "env_issue"; + throw new Error("LANGBOT_FRONTEND_URL is not configured."); + } + + const fakeProvider = await ensureFakeProvider(); + const setupConfig = await configureFakeProvider(fakeProvider.url, healthyFakeProviderConfig(), true); + result.fake_provider = { + ...result.fake_provider, + ...fakeProvider, + config: setupConfig.config || healthyFakeProviderConfig(), + }; + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + result.status = "env_issue"; + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the fake provider pipeline."); + } + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + const wizard = await skipWizard({ backendUrl, token: auth.token }); + if (wizard.status !== "pass") { + result.status = "fail"; + throw new Error(wizard.reason || "Failed to mark the local QA wizard as skipped."); + } + + const provider = await ensureProvider({ + backendUrl, + token: auth.token, + name: providerName, + requester, + baseUrl: fakeProvider.base_url, + }); + result.provider = provider; + + const model = await ensureModel({ + backendUrl, + token: auth.token, + providerUuid: provider.uuid, + name: modelName, + }); + result.model = model; + + const pipeline = await ensurePipeline({ + backendUrl, + token: auth.token, + name: pipelineName, + modelUuid: model.uuid, + }); + Object.assign(result, pipeline); + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(pipeline.pipeline_id)}`; + + const runConfig = await configureFakeProvider(fakeProvider.url, targetFakeProviderConfig(), true); + result.fake_provider.config = runConfig.config || targetFakeProviderConfig(); + + if (writeEnv) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_E2E_LOGIN_USER: user, + LANGBOT_FAKE_PROVIDER_URL: fakeProvider.url, + LANGBOT_FAKE_PROVIDER_BASE_URL: fakeProvider.base_url, + LANGBOT_FAKE_PROVIDER_PID: fakeProvider.pid ? String(fakeProvider.pid) : "", + LANGBOT_FAKE_PROVIDER_PROVIDER_UUID: provider.uuid, + LANGBOT_FAKE_PROVIDER_MODEL_UUID: model.uuid, + LANGBOT_FAKE_PROVIDER_PIPELINE_URL: result.pipeline_url, + LANGBOT_FAKE_PROVIDER_PIPELINE_NAME: pipelineName, + }); + result.wrote_env = true; + } + + result.status = "pass"; + result.reason = `Fake provider pipeline is configured with ${requester}/${modelName}.`; +} catch (error) { + result.status = result.status === "env_issue" ? "env_issue" : "fail"; + result.reason = result.reason || safeReason(error.message); +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function ensureFakeProvider() { + const envUrl = normalizeProviderRootUrl(env.LANGBOT_FAKE_PROVIDER_URL || ""); + if (envUrl && await fakeProviderHealthy(envUrl) && await fakeProviderConfigurable(envUrl)) { + return { + url: envUrl, + base_url: `${envUrl}/v1`, + pid: null, + reused: true, + }; + } + + const state = await readState(fakeStatePath); + const stateUrl = normalizeProviderRootUrl(state.url || ""); + if (stateUrl && await fakeProviderHealthy(stateUrl)) { + if (await fakeProviderConfigurable(stateUrl)) { + return { + url: stateUrl, + base_url: state.base_url || `${stateUrl}/v1`, + pid: Number.isInteger(state.pid) ? state.pid : null, + reused: true, + }; + } + if (Number.isInteger(state.pid)) await stopProcess(state.pid); + } + + await mkdir(fakeStateDir, { recursive: true }); + await writeFile(fakeStatePath, `${JSON.stringify({ status: "starting", started_at: new Date().toISOString() }, null, 2)}\n`, "utf8"); + const stdout = await open(fakeStdoutPath, "a"); + const stderr = await open(fakeStderrPath, "a"); + const scriptPath = resolve("scripts/e2e/fake-openai-provider.mjs"); + const host = env.LANGBOT_FAKE_PROVIDER_HOST || "127.0.0.1"; + const port = env.LANGBOT_FAKE_PROVIDER_PORT || "0"; + const child = spawn(process.execPath, [ + scriptPath, + `--host=${host}`, + `--port=${port}`, + `--state-file=${fakeStatePath}`, + ], { + cwd: resolve("."), + detached: true, + env: { + ...env, + LANGBOT_FAKE_PROVIDER_MODEL_NAME: modelName, + }, + stdio: ["ignore", stdout.fd, stderr.fd], + }); + child.unref(); + await stdout.close(); + await stderr.close(); + + const started = await waitForFakeProviderState(fakeStatePath, child.pid, 10_000); + if (!started.url || !await fakeProviderHealthy(started.url) || !await fakeProviderConfigurable(started.url)) { + throw new Error(`Fake provider did not become healthy. See ${fakeStderrPath}`); + } + + return { + url: started.url, + base_url: started.base_url || `${started.url}/v1`, + pid: child.pid ?? started.pid ?? null, + reused: false, + }; +} + +async function configureFakeProvider(rootUrl, config, resetRequestCount) { + const response = await fetch(`${normalizeProviderRootUrl(rootUrl)}/__qa/config`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + config, + reset_request_count: resetRequestCount, + }), + signal: AbortSignal.timeout(3000), + }); + const json = await response.json().catch(() => ({})); + if (!response.ok || json.ok !== true) { + throw new Error(`Fake provider config failed with HTTP ${response.status}.`); + } + return json; +} + +async function fakeProviderHealthy(rootUrl) { + try { + const response = await fetch(`${rootUrl.replace(/\/$/, "")}/healthz`, { + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) return false; + const json = await response.json().catch(() => ({})); + return json.ok === true; + } catch { + return false; + } +} + +async function fakeProviderConfigurable(rootUrl) { + try { + const response = await fetch(`${rootUrl.replace(/\/$/, "")}/__qa/config`, { + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) return false; + const json = await response.json().catch(() => ({})); + return json.ok === true && json.config && typeof json.config === "object"; + } catch { + return false; + } +} + +async function stopProcess(pid) { + try { + process.kill(pid, "SIGTERM"); + } catch { + return; + } + await sleep(500); +} + +async function waitForFakeProviderState(path, expectedPid, timeoutMs) { + const startedAt = Date.now(); + let lastState = {}; + while (Date.now() - startedAt < timeoutMs) { + const state = await readState(path); + if (state.url && (!expectedPid || state.pid === expectedPid)) return state; + lastState = state; + await sleep(150); + } + return lastState; +} + +async function readState(path) { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch { + return {}; + } +} + +function normalizeProviderRootUrl(value) { + const trimmed = String(value || "").trim().replace(/\/$/, ""); + return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; +} + +function healthyFakeProviderConfig() { + return { + response_text: "OK", + first_token_delay_ms: 25, + chunk_delay_ms: 10, + chunk_count: 0, + fault_status: 500, + fail_first_n: 0, + fail_every_n: 0, + fail_after_first_chunk: false, + dynamic_response: true, + }; +} + +function targetFakeProviderConfig() { + return { + response_text: env.LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT || "OK", + first_token_delay_ms: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS, 25), + chunk_delay_ms: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS, 10), + chunk_count: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_CHUNK_COUNT, 0), + fault_status: httpFaultStatus(env.LANGBOT_FAKE_PROVIDER_FAULT_STATUS, 500), + fail_first_n: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N, 0), + fail_every_n: nonNegativeInteger(env.LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N, 0), + fail_after_first_chunk: envBool(env.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK, false), + dynamic_response: envBool(env.LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE, true), + }; +} + +async function skipWizard({ backendUrl, token }) { + const response = await apiJson(backendUrl, "/api/v1/system/wizard/completed", { + method: "POST", + token, + body: { status: "skipped" }, + }); + const ok = response.status < 400 && response.json.code === 0; + return { + status: ok ? "pass" : "fail", + http_status: response.status, + code: response.json.code ?? null, + reason: ok ? "Wizard marked skipped for local QA." : response.json.msg || "Wizard status update failed.", + }; +} + +async function ensureProvider({ backendUrl, token, name, requester, baseUrl }) { + const list = await apiJson(backendUrl, "/api/v1/provider/providers", { token }); + if (isApiFailure(list)) { + throw new Error(list.json.msg || "Failed to list providers."); + } + const providers = list.json.data?.providers || []; + const existing = providers.find((provider) => ( + provider.name === name + || (provider.requester === requester && String(provider.base_url || "").replace(/\/$/, "") === baseUrl.replace(/\/$/, "")) + )); + const body = { + name, + requester, + base_url: baseUrl, + api_keys: [env.LANGBOT_FAKE_PROVIDER_API_KEY || "langbot-fake-provider-key"], + }; + + if (existing?.uuid) { + const update = await apiJson(backendUrl, `/api/v1/provider/providers/${encodeURIComponent(existing.uuid)}`, { + method: "PUT", + token, + body, + }); + if (isApiFailure(update)) { + throw new Error(update.json.msg || "Failed to update fake provider."); + } + return { + uuid: existing.uuid, + name, + requester, + created: false, + updated: true, + }; + } + + const create = await apiJson(backendUrl, "/api/v1/provider/providers", { + method: "POST", + token, + body, + }); + const uuid = create.json.data?.uuid || ""; + if (isApiFailure(create) || !uuid) { + throw new Error(create.json.msg || "Failed to create fake provider."); + } + return { + uuid, + name, + requester, + created: true, + updated: false, + }; +} + +async function ensureModel({ backendUrl, token, providerUuid, name }) { + const list = await apiJson(backendUrl, `/api/v1/provider/models/llm?provider_uuid=${encodeURIComponent(providerUuid)}`, { token }); + if (isApiFailure(list)) { + throw new Error(list.json.msg || "Failed to list fake provider models."); + } + const models = list.json.data?.models || []; + const existing = models.find((model) => model.name === name); + const body = { + name, + provider_uuid: providerUuid, + abilities: [], + context_length: positiveInteger(env.LANGBOT_FAKE_PROVIDER_CONTEXT_LENGTH, 8192), + extra_args: {}, + prefered_ranking: 0, + }; + let modelUuid = existing?.uuid || ""; + let created = false; + let updated = false; + + if (modelUuid) { + const update = await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}`, { + method: "PUT", + token, + body, + }); + if (isApiFailure(update)) { + throw new Error(update.json.msg || "Failed to update fake provider model."); + } + updated = true; + } else { + const create = await apiJson(backendUrl, "/api/v1/provider/models/llm", { + method: "POST", + token, + body, + }); + modelUuid = create.json.data?.uuid || ""; + if (isApiFailure(create) || !modelUuid) { + throw new Error(create.json.msg || "Failed to create fake provider model."); + } + created = true; + } + + const test = await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}/test`, { + method: "POST", + token, + body: { extra_args: {} }, + }); + if (isApiFailure(test)) { + throw new Error(safeReason(test.json.msg || test.json.message || "Fake provider model test failed.")); + } + + return { + uuid: modelUuid, + name, + created, + updated, + test_status: "pass", + test_reason: "", + }; +} + +async function ensurePipeline({ backendUrl, token, name, modelUuid }) { + const list = await apiJson(backendUrl, "/api/v1/pipelines", { token }); + if (isApiFailure(list)) { + throw new Error(list.json.msg || "Failed to list pipelines."); + } + const pipelines = list.json.data?.pipelines || []; + let pipeline = pipelines.find((item) => item.name === name) || null; + let created = false; + + if (!pipeline) { + const create = await apiJson(backendUrl, "/api/v1/pipelines", { + method: "POST", + token, + body: { + name, + description: QA_RESOURCE_DESCRIPTION, + emoji: "QA", + }, + }); + const pipelineId = create.json.data?.uuid || ""; + if (isApiFailure(create) || !pipelineId) { + throw new Error(create.json.msg || "Failed to create fake provider pipeline."); + } + created = true; + pipeline = { uuid: pipelineId }; + } + + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token }); + pipeline = loaded.json.data?.pipeline || null; + if (isApiFailure(loaded) || !pipeline?.uuid) { + throw new Error(loaded.json.msg || "Failed to load fake provider pipeline."); + } + + const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; + const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; + const existingLocalAgentConfig = ai["local-agent"] && typeof ai["local-agent"] === "object" + ? ai["local-agent"] + : {}; + const localAgentConfig = { + timeout: 60, + prompt: [{ role: "system", content: "You are a deterministic QA assistant. Reply exactly as instructed." }], + "remove-think": false, + "knowledge-bases": [], + "box-session-id-template": "{launcher_type}_{launcher_id}", + "retrieval-top-k": 5, + "rerank-model": "", + "rerank-top-k": 5, + "max-tool-iterations": 20, + "tool-execution-mode": "parallel", + "max-tool-result-chars": 20000, + "context-history-fetch-limit": 20, + "context-window-tokens": 8192, + "context-reserve-tokens": 1024, + "context-keep-recent-tokens": 2048, + "context-summary-tokens": 1024, + ...existingLocalAgentConfig, + // Current backend truncation still reads this field directly. + "max-round": positiveInteger(existingLocalAgentConfig["max-round"], 10), + model: { + primary: modelUuid, + fallbacks: [], + }, + }; + const updatedConfig = { + ...config, + ai: { + ...ai, + runner: { + ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), + id: RUNNER_ID, + runner: RUNNER_ID, + "expire-time": 0, + }, + "local-agent": localAgentConfig, + }, + }; + + const update = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { + method: "PUT", + token, + body: { + name, + description: QA_RESOURCE_DESCRIPTION, + emoji: "QA", + config: updatedConfig, + }, + }); + if (isApiFailure(update)) { + throw new Error(update.json.msg || "Failed to update fake provider pipeline."); + } + + return { + pipeline_id: pipeline.uuid, + pipeline_name: name, + created, + updated: true, + }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json.code !== undefined && response.json.code !== 0); +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function nonNegativeInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + +function httpFaultStatus(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 400 && parsed <= 599 ? parsed : fallback; +} + +function envBool(value, fallback) { + if (value === undefined || value === "") return fallback; + if (/^(1|true|yes|on)$/i.test(String(value))) return true; + if (/^(0|false|no|off)$/i.test(String(value))) return false; + return fallback; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function safeReason(value) { + return redact(String(value || "")).slice(0, 1000); +} + +async function upsertEnvLocal(path, updates) { + await mkdir(dirname(path), { recursive: true }); + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const seen = new Set(); + const next = lines.map((line) => { + const trimmed = line.trim(); + const equals = trimmed.indexOf("="); + if (equals <= 0 || trimmed.startsWith("#")) return line; + const key = trimmed.slice(0, equals).trim(); + if (!(key in updates)) return line; + seen.add(key); + return `${key}=${updates[key]}`; + }); + for (const [key, value] of Object.entries(updates)) { + if (!seen.has(key)) next.push(`${key}=${value}`); + } + await writeFile(path, `${next.filter((line, index) => line !== "" || index < next.length - 1).join("\n")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs b/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs new file mode 100755 index 0000000..b2cf9e7 --- /dev/null +++ b/skills/scripts/e2e/ensure-langrag-sentinel-kb.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + ensureEvidence, + evidencePaths, + loadEnvFiles, + resetAndAuthLocalUser, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = env.LBS_CASE_ID || "ensure-langrag-sentinel-kb"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const user = env.LANGBOT_E2E_LOGIN_USER || ""; +const password = env.LANGBOT_E2E_LOGIN_PASSWORD || "LangBotE2ELocalPass!2026"; +const expectedText = env.LANGBOT_E2E_EXPECTED_TEXT || "azalea-cobalt-7421"; +const query = env.LANGBOT_E2E_RETRIEVE_QUERY || "What is the local agent runner retrieval sentinel?"; +const writeEnv = process.argv.includes("--write-env"); +const checkOnly = process.argv.includes("--check-only"); +const envLocalPath = resolve("skills/.env.local"); +const kbName = env.LANGBOT_E2E_RAG_KB_NAME || "qa-local-agent-rag"; +const sentinelPath = resolve(env.LANGBOT_E2E_RAG_SENTINEL_DOC || "skills/langbot-testing/fixtures/rag/sentinel-doc.txt"); +const waitMs = Number(env.LANGBOT_E2E_RAG_WAIT_MS || 180_000); + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + backend_url: backendUrl, + expected_text: expectedText, + query, + kb_uuid: "", + kb_name: "", + kb_created: false, + uploaded_file_id: "", + store_task_id: "", + embedding_model_uuid: "", + engine_plugin_id: "", + checked_bases: [], + file_statuses: [], + wrote_env: false, + evidence: { + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic"], +}; + +try { + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + if (!user) throw new Error("LANGBOT_E2E_LOGIN_USER is required."); + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + const basesResponse = await apiJson(backendUrl, "/api/v1/knowledge/bases", { token: auth.token }); + if (basesResponse.status >= 400 || basesResponse.json.code !== 0) { + throw new Error(basesResponse.json.msg || `Failed to list knowledge bases: HTTP ${basesResponse.status}.`); + } + + let bases = basesResponse.json.data?.bases || []; + await findSentinelBase(backendUrl, auth.token, bases, result); + + if (!result.kb_uuid && !checkOnly) { + const targetBase = bases.find((base) => { + const uuid = base.uuid || base.id || ""; + return (base.name || "") === kbName && !hasRetrieveFailure(result.checked_bases, uuid); + }); + result.kb_uuid = targetBase?.uuid || targetBase?.id || ""; + result.kb_name = targetBase?.name || kbName; + + if (!result.kb_uuid) { + const setup = await createKnowledgeBase(backendUrl, auth.token, kbName); + result.kb_uuid = setup.kbUuid; + result.kb_name = kbName; + result.kb_created = true; + result.embedding_model_uuid = setup.embeddingModelUuid; + result.engine_plugin_id = setup.enginePluginId; + } + + const upload = await uploadDocument(backendUrl, auth.token, sentinelPath); + result.uploaded_file_id = upload.fileId; + + const store = await apiJson(backendUrl, `/api/v1/knowledge/bases/${encodeURIComponent(result.kb_uuid)}/files`, { + method: "POST", + token: auth.token, + body: { file_id: upload.fileId }, + }); + if (store.status >= 400 || store.json.code !== 0) { + throw new Error(store.json.msg || `Failed to store file in knowledge base: HTTP ${store.status}.`); + } + result.store_task_id = store.json.data?.task_id || ""; + + const ready = await waitForSentinel(backendUrl, auth.token, result.kb_uuid, query, expectedText, waitMs); + result.file_statuses = ready.fileStatuses; + if (ready.matched) { + result.checked_bases.push(ready.checked); + } + } + + if (!result.kb_uuid) { + result.status = "env_issue"; + result.reason = checkOnly + ? `No existing knowledge base retrieved expected sentinel: ${expectedText}` + : `Could not create or verify LangRAG sentinel knowledge base: ${expectedText}`; + } else { + if (writeEnv) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_LOCAL_AGENT_RAG_KB_UUID: result.kb_uuid, + }); + result.wrote_env = true; + } + result.status = "pass"; + result.reason = `Found LangRAG sentinel knowledge base: ${result.kb_uuid}`; + } +} catch (error) { + result.status = /not configured|required|No existing knowledge base/.test(error.message) ? "env_issue" : "fail"; + result.reason = error.message; +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function findSentinelBase(backendUrl, token, bases, result) { + for (const base of bases) { + const uuid = base.uuid || base.id || ""; + if (!uuid) continue; + const checked = await retrieveSentinel(backendUrl, token, uuid, base.name || "", result.query, result.expected_text); + result.checked_bases.push(checked); + if (checked.matched) { + result.kb_uuid = uuid; + result.kb_name = checked.name; + return; + } + } +} + +async function createKnowledgeBase(backendUrl, token, name) { + const enginesResponse = await apiJson(backendUrl, "/api/v1/knowledge/engines", { token }); + if (enginesResponse.status >= 400 || enginesResponse.json.code !== 0) { + throw new Error(enginesResponse.json.msg || `Failed to list knowledge engines: HTTP ${enginesResponse.status}.`); + } + const engines = enginesResponse.json.data?.engines || []; + const engine = engines.find((item) => item.plugin_id === "langbot-team/LangRAG") + || engines.find((item) => JSON.stringify(item.name || item.label || "").includes("LangRAG")); + const enginePluginId = engine?.plugin_id || ""; + if (!enginePluginId) throw new Error("LangRAG knowledge engine is not installed."); + + const embeddingModelUuid = await pickEmbeddingModel(backendUrl, token); + const create = await apiJson(backendUrl, "/api/v1/knowledge/bases", { + method: "POST", + token, + body: { + name, + description: "Automated LangBot agent-runner RAG sentinel knowledge base.", + knowledge_engine_plugin_id: enginePluginId, + creation_settings: { + embedding_model_uuid: embeddingModelUuid, + index_type: "chunk", + chunk_size: 512, + overlap: 50, + }, + retrieval_settings: { + top_k: 5, + search_type: "vector", + query_rewrite: "off", + rerank: "off", + context_window: 0, + }, + }, + }); + const kbUuid = create.json.data?.uuid || ""; + if (create.status >= 400 || create.json.code !== 0 || !kbUuid) { + throw new Error(create.json.msg || `Failed to create knowledge base: HTTP ${create.status}.`); + } + return { kbUuid, embeddingModelUuid, enginePluginId }; +} + +async function pickEmbeddingModel(backendUrl, token) { + const configured = env.LANGBOT_LOCAL_AGENT_RAG_EMBEDDING_MODEL_UUID || env.LANGBOT_RAG_EMBEDDING_MODEL_UUID || ""; + if (configured) return configured; + + const modelsResponse = await apiJson(backendUrl, "/api/v1/provider/models/embedding", { token }); + if (modelsResponse.status >= 400 || modelsResponse.json.code !== 0) { + throw new Error(modelsResponse.json.msg || `Failed to list embedding models: HTTP ${modelsResponse.status}.`); + } + const models = modelsResponse.json.data?.models || []; + const preferred = models.find((model) => /chroma|MiniLM/i.test(model.name || "")) + || models.find((model) => /text-embedding-3-small/i.test(model.name || "")) + || [...models].sort((a, b) => (a.prefered_ranking ?? 9999) - (b.prefered_ranking ?? 9999))[0]; + const uuid = preferred?.uuid || ""; + if (!uuid) throw new Error("No embedding model is configured."); + return uuid; +} + +async function uploadDocument(backendUrl, token, path) { + const bytes = await readFile(path); + const form = new FormData(); + form.append("file", new Blob([bytes], { type: "text/plain" }), "sentinel-doc.txt"); + const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/files/documents`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: form, + }); + const json = await response.json().catch(() => ({})); + const fileId = json.data?.file_id || ""; + if (response.status >= 400 || json.code !== 0 || !fileId) { + throw new Error(json.msg || `Failed to upload sentinel document: HTTP ${response.status}.`); + } + return { fileId }; +} + +async function waitForSentinel(backendUrl, token, kbUuid, query, expectedText, timeoutMs) { + const started = Date.now(); + let fileStatuses = []; + let lastChecked = null; + while (Date.now() - started < timeoutMs) { + const files = await apiJson(backendUrl, `/api/v1/knowledge/bases/${encodeURIComponent(kbUuid)}/files`, { token }); + fileStatuses = files.json.data?.files || fileStatuses; + lastChecked = await retrieveSentinel(backendUrl, token, kbUuid, kbName, query, expectedText); + if (lastChecked.matched) { + return { matched: true, fileStatuses, checked: lastChecked }; + } + if (fileStatuses.some((item) => item.status === "failed")) break; + await sleep(2_000); + } + result.reason = lastChecked?.msg + || `LangRAG sentinel was not retrievable within ${timeoutMs}ms; file statuses: ${JSON.stringify(fileStatuses)}`; + result.kb_uuid = ""; + return { matched: false, fileStatuses, checked: lastChecked }; +} + +async function retrieveSentinel(backendUrl, token, uuid, name, query, expectedText) { + const retrieve = await apiJson(backendUrl, `/api/v1/knowledge/bases/${encodeURIComponent(uuid)}/retrieve`, { + method: "POST", + token, + body: { query }, + }); + const text = JSON.stringify(retrieve.json.data?.results || []); + return { + uuid, + name, + http_status: retrieve.status, + code: retrieve.json.code ?? null, + msg: retrieve.json.msg || "", + matched: text.includes(expectedText), + }; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function hasRetrieveFailure(checkedBases, uuid) { + const checked = checkedBases.find((item) => item.uuid === uuid); + return checked && (checked.http_status >= 500 || (typeof checked.code === "number" && checked.code < 0)); +} + +async function upsertEnvLocal(path, values) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const keys = new Set(Object.keys(values)); + const output = []; + for (const line of lines) { + const match = line.match(/^([A-Z][A-Z0-9_]*)=/); + if (match && keys.has(match[1])) { + output.push(`${match[1]}=${values[match[1]]}`); + keys.delete(match[1]); + } else if (line !== "" || output.length > 0) { + output.push(line); + } + } + if (keys.size > 0 && output.length > 0 && output[output.length - 1] !== "") output.push(""); + for (const key of keys) output.push(`${key}=${values[key]}`); + await writeFile(path, `${output.join("\n").replace(/\n+$/, "")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/ensure-local-agent-pipeline.mjs b/skills/scripts/e2e/ensure-local-agent-pipeline.mjs new file mode 100755 index 0000000..da43362 --- /dev/null +++ b/skills/scripts/e2e/ensure-local-agent-pipeline.mjs @@ -0,0 +1,609 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + bodyText, + createBrowser, + ensureEvidence, + evidencePaths, + loadEnvFiles, + redact, + resetAndAuthLocalUser, + safeScreenshot, + setBrowserToken, + verifyBrowserToken, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const RUNNER_ID = "local-agent"; +const SPACE_PROVIDER_UUID = "00000000-0000-0000-0000-000000000000"; +const DEFAULT_PIPELINE_NAME = "Agent QA Local Agent Debug Chat"; +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; +const DEFAULT_MODEL_TEST_LIMIT = 8; +const DEFAULT_MODEL_FALLBACK_COUNT = 3; +const caseId = "ensure-local-agent-pipeline"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const writeEnv = process.argv.includes("--write-env"); +const pipelineName = env.LANGBOT_E2E_CREATE_PIPELINE_NAME || env.LANGBOT_LOCAL_AGENT_PIPELINE_NAME || DEFAULT_PIPELINE_NAME; +const frontendUrl = env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const envLocalPath = resolve("skills/.env.local"); + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + frontend_url: frontendUrl, + backend_url: backendUrl, + pipeline_name: pipelineName, + pipeline_id: "", + pipeline_url: "", + runner_id: RUNNER_ID, + selected_model_id: "", + selected_model_name: "", + fallback_model_ids: [], + model_count: 0, + space_model_count: 0, + scanned_space_model_count: 0, + tested_model_count: 0, + model_tests: [], + created: false, + updated: false, + wrote_env: false, + auth: null, + wizard: null, + browser_token_check: null, + page_signal: "", + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic", "console", "network", "screenshot"], +}; + +let browser; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + result.status = "env_issue"; + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API."); + } + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + result.auth = { + source: "local_recovery_login", + user, + backend_token_check: auth.check, + }; + + const wizard = await skipWizard({ backendUrl, token: auth.token }); + result.wizard = wizard; + if (wizard.status !== "pass") { + result.status = "fail"; + throw new Error(wizard.reason || "Failed to mark the local QA wizard as skipped."); + } + + const prepared = await ensureLocalAgentPipeline({ + backendUrl, + token: auth.token, + pipelineName, + runnerId: RUNNER_ID, + }); + Object.assign(result, prepared); + if (result.pipeline_id) { + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; + } + + if (writeEnv && result.pipeline_id) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_E2E_LOGIN_USER: user, + LANGBOT_PIPELINE_URL: result.pipeline_url, + LANGBOT_PIPELINE_NAME: result.pipeline_name || pipelineName, + LANGBOT_LOCAL_AGENT_PIPELINE_URL: result.pipeline_url, + LANGBOT_LOCAL_AGENT_PIPELINE_NAME: result.pipeline_name || pipelineName, + ...(result.selected_model_id ? { + LANGBOT_LOCAL_AGENT_MODEL_UUID: result.selected_model_id, + LANGBOT_E2E_MODEL_UUID: result.selected_model_id, + } : {}), + }); + result.wrote_env = true; + } + + browser = await createBrowser(paths); + const { page } = browser; + await setBrowserToken(page, frontendUrl, auth.token); + const browserCheck = await verifyBrowserToken(page, backendUrl); + result.browser_token_check = browserCheck; + if (!browserCheck.authenticated) { + throw new Error(browserCheck.reason || "Browser token check failed after setup."); + } + await page.goto(result.pipeline_url || frontendUrl, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const text = await bodyText(page); + result.page_signal = ["Pipelines", "流水线", pipelineName].find((signal) => text.includes(signal)) || ""; +} catch (error) { + result.status = result.status === "env_issue" ? "env_issue" : "fail"; + result.reason = result.reason || error.message; +} finally { + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); + if (browser) await browser.close().catch(() => {}); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function skipWizard({ backendUrl, token }) { + const response = await apiJson(backendUrl, "/api/v1/system/wizard/completed", { + method: "POST", + token, + body: { status: "skipped" }, + }); + const ok = response.status < 400 && response.json.code === 0; + return { + status: ok ? "pass" : "fail", + http_status: response.status, + code: response.json.code ?? null, + reason: ok ? "Wizard marked skipped for local QA." : response.json.msg || "Wizard status update failed.", + }; +} + +async function ensureLocalAgentPipeline({ backendUrl, token, pipelineName, runnerId }) { + const [pipelineList, modelList] = await Promise.all([ + apiJson(backendUrl, "/api/v1/pipelines", { token }), + apiJson(backendUrl, "/api/v1/provider/models/llm", { token }), + ]); + + if (isApiFailure(pipelineList)) { + return { + status: "fail", + reason: pipelineList.json.msg || "Failed to list pipelines.", + list_status: pipelineList.status, + }; + } + if (isApiFailure(modelList)) { + return { + status: "fail", + reason: modelList.json.msg || "Failed to list LLM models.", + model_status: modelList.status, + }; + } + + const models = modelList.json.data?.models || []; + const skippedModelIds = new Set( + String(env.LANGBOT_E2E_SKIP_MODEL_UUIDS || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ); + const skippedModelNames = new Set( + String(env.LANGBOT_E2E_SKIP_MODEL_NAMES || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ); + const spaceModels = models.filter((model) => isSpaceModel(model) && !skippedModelIds.has(model.uuid)); + const pipelines = pipelineList.json.data?.pipelines || []; + let pipeline = pipelines.find((item) => item.name === pipelineName) || null; + let created = false; + + if (!pipeline) { + const createdResponse = await apiJson(backendUrl, "/api/v1/pipelines", { + method: "POST", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for AgentRunner Debug Chat smoke tests.", + emoji: "QA", + }, + }); + if (isApiFailure(createdResponse)) { + return { + status: "fail", + reason: createdResponse.json.msg || "Failed to create pipeline.", + create_status: createdResponse.status, + model_count: models.length, + space_model_count: spaceModels.length, + }; + } + const pipelineId = createdResponse.json.data?.uuid || ""; + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { token }); + pipeline = loaded.json.data?.pipeline || null; + created = true; + } + + if (!pipeline?.uuid) { + return { + status: "fail", + reason: "Pipeline was not created or resolved.", + model_count: models.length, + space_model_count: spaceModels.length, + }; + } + + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token }); + if (isApiFailure(loaded) || !loaded.json.data?.pipeline) { + return { + status: "fail", + reason: loaded.json.msg || "Failed to load pipeline.", + get_status: loaded.status, + pipeline_id: pipeline.uuid, + model_count: models.length, + space_model_count: spaceModels.length, + }; + } + pipeline = loaded.json.data.pipeline; + + const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; + const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; + const rawExistingLocalAgentConfig = ai["local-agent"] && typeof ai["local-agent"] === "object" + ? ai["local-agent"] + : {}; + const existingLocalAgentConfig = rawExistingLocalAgentConfig; + const existingModel = existingLocalAgentConfig.model && typeof existingLocalAgentConfig.model === "object" + ? existingLocalAgentConfig.model + : {}; + const requestedModelId = env.LANGBOT_LOCAL_AGENT_MODEL_UUID || env.LANGBOT_E2E_MODEL_UUID || ""; + const selected = await selectWorkingSpaceModel({ + backendUrl, + token, + models, + skippedModelIds, + skippedModelNames, + requestedModelId, + existingModelId: existingModel.primary || "", + }); + const selectedModelId = selected.selected_model_id || ""; + const localAgentConfig = { + timeout: 300, + prompt: [{ role: "system", content: "You are a helpful assistant." }], + "remove-think": false, + "knowledge-bases": [], + "box-session-id-template": "{launcher_type}_{launcher_id}", + "retrieval-top-k": 5, + "rerank-model": "", + "rerank-top-k": 5, + "max-tool-iterations": 20, + "tool-execution-mode": "parallel", + "max-tool-result-chars": 20000, + "context-history-fetch-limit": 50, + "context-window-tokens": 200000, + "context-reserve-tokens": 16384, + "context-keep-recent-tokens": 20000, + "context-summary-tokens": 8000, + ...existingLocalAgentConfig, + // Current backend truncation still reads this field directly. + "max-round": positiveInteger(existingLocalAgentConfig["max-round"], 10), + model: { + primary: selectedModelId, + fallbacks: selected.fallback_model_ids || [], + }, + }; + const updatedConfig = { + ...config, + ai: { + ...ai, + runner: { + ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), + id: runnerId, + runner: runnerId, + "expire-time": 0, + }, + "local-agent": localAgentConfig, + }, + }; + + const updateResponse = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { + method: "PUT", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for AgentRunner Debug Chat smoke tests.", + emoji: "QA", + config: updatedConfig, + }, + }); + if (isApiFailure(updateResponse)) { + return { + status: "fail", + reason: updateResponse.json.msg || "Failed to update pipeline config.", + update_status: updateResponse.status, + pipeline_id: pipeline.uuid, + model_count: models.length, + space_model_count: spaceModels.length, + scanned_space_model_count: selected.scanned_space_model_count, + tested_model_count: selected.tested_model_count, + model_tests: selected.model_tests, + selected_model_id: selectedModelId, + selected_model_name: selected.selected_model_name, + fallback_model_ids: selected.fallback_model_ids, + }; + } + + return { + status: selectedModelId ? "pass" : "env_issue", + reason: selectedModelId + ? `Local-agent pipeline is configured for Debug Chat with Space model ${selected.selected_model_name || selectedModelId} and ${selected.fallback_model_ids.length} fallback(s).` + : selected.reason || "No working Space LLM model is configured in this LangBot instance.", + pipeline_id: pipeline.uuid, + pipeline_name: pipelineName, + model_count: models.length, + space_model_count: spaceModels.length, + scanned_space_model_count: selected.scanned_space_model_count, + tested_model_count: selected.tested_model_count, + model_tests: selected.model_tests, + selected_model_id: selectedModelId, + selected_model_name: selected.selected_model_name, + fallback_model_ids: selected.fallback_model_ids, + created, + updated: true, + }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json.code !== undefined && response.json.code !== 0); +} + +function isSpaceModel(model) { + const provider = model?.provider && typeof model.provider === "object" ? model.provider : {}; + return model?.provider_uuid === SPACE_PROVIDER_UUID + || provider.uuid === SPACE_PROVIDER_UUID + || provider.requester === "space-chat-completions" + || provider.name === "LangBot Models"; +} + +async function selectWorkingSpaceModel({ + backendUrl, + token, + models, + skippedModelIds, + skippedModelNames, + requestedModelId, + existingModelId, +}) { + const modelTests = []; + const testLimit = positiveInteger(env.LANGBOT_E2E_MODEL_TEST_LIMIT, DEFAULT_MODEL_TEST_LIMIT); + const fallbackCount = positiveInteger(env.LANGBOT_E2E_MODEL_FALLBACK_COUNT, DEFAULT_MODEL_FALLBACK_COUNT); + const workingModels = []; + const spaceModels = rankModels(models.filter((model) => ( + model.uuid + && isSpaceModel(model) + && !skippedModelIds.has(model.uuid) + && !skippedModelNames.has(model.name) + ))); + const requestedModel = requestedModelId + ? spaceModels.find((model) => model.uuid === requestedModelId) || null + : null; + const existingModel = existingModelId + ? spaceModels.find((model) => model.uuid === existingModelId) || null + : null; + const candidates = uniqueCandidates([ + ...(requestedModel ? [existingCandidate(requestedModel, "requested")] : []), + ...(existingModel ? [existingCandidate(existingModel, "existing-pipeline")] : []), + ...spaceModels.map((model) => existingCandidate(model, "configured-space")), + ]); + + let scanResult = { status: "skipped", models: [], reason: "" }; + if (env.LANGBOT_E2E_SCAN_SPACE_MODELS !== "false") { + scanResult = await scanSpaceModels({ backendUrl, token }); + if (scanResult.status === "pass") { + const knownNames = new Set(spaceModels.map((model) => model.name)); + candidates.push(...scanResult.models + .filter((model) => model.name && !knownNames.has(model.name) && !skippedModelNames.has(model.name)) + .map((model) => scannedCandidate(model))); + } + } + + const unique = uniqueCandidates(candidates); + for (const candidate of unique.slice(0, testLimit)) { + const test = await ensureAndTestModel({ backendUrl, token, candidate }); + modelTests.push(test); + if (test.status === "pass" && test.model_uuid) { + workingModels.push(test); + if (workingModels.length >= fallbackCount + 1) break; + } + } + + if (workingModels.length > 0) { + const [primary, ...fallbacks] = workingModels; + return { + status: "pass", + reason: "", + selected_model_id: primary.model_uuid, + selected_model_name: primary.model_name, + fallback_model_ids: fallbacks.map((model) => model.model_uuid), + scanned_space_model_count: scanResult.models.length, + tested_model_count: modelTests.length, + model_tests: modelTests, + }; + } + + const baseReason = unique.length === 0 + ? scanResult.reason || "No Space LLM model candidates are available." + : `No working Space LLM model found after testing ${modelTests.length} candidate(s).`; + return { + status: "env_issue", + reason: requestedModelId && !requestedModel + ? `Requested Space LLM model ${requestedModelId} is missing or skipped; ${baseReason}` + : baseReason, + selected_model_id: "", + selected_model_name: "", + fallback_model_ids: [], + scanned_space_model_count: scanResult.models.length, + tested_model_count: modelTests.length, + model_tests: modelTests, + }; +} + +async function scanSpaceModels({ backendUrl, token }) { + const response = await apiJson( + backendUrl, + `/api/v1/provider/providers/${encodeURIComponent(SPACE_PROVIDER_UUID)}/scan-models?type=llm`, + { token }, + ); + if (isApiFailure(response)) { + return { + status: "env_issue", + models: [], + reason: safeReason(response.json.msg || response.json.message || "Failed to scan Space LLM models."), + }; + } + return { + status: "pass", + models: response.json.data?.models || [], + reason: "", + }; +} + +async function ensureAndTestModel({ backendUrl, token, candidate }) { + let modelUuid = candidate.uuid || ""; + let created = false; + if (!modelUuid) { + const create = await apiJson(backendUrl, "/api/v1/provider/models/llm", { + method: "POST", + token, + body: { + name: candidate.name, + provider_uuid: SPACE_PROVIDER_UUID, + abilities: candidate.abilities || [], + context_length: candidate.context_length ?? null, + extra_args: {}, + prefered_ranking: positiveInteger(candidate.prefered_ranking, 0), + }, + }); + modelUuid = create.json.data?.uuid || ""; + if (isApiFailure(create) || !modelUuid) { + return modelTestResult(candidate, { + status: "fail", + reason: safeReason(create.json.msg || "Failed to create scanned Space model."), + http_status: create.status, + }); + } + created = true; + } + + const test = await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}/test`, { + method: "POST", + token, + body: { extra_args: {} }, + }); + const passed = !isApiFailure(test); + if (!passed && created) { + await apiJson(backendUrl, `/api/v1/provider/models/llm/${encodeURIComponent(modelUuid)}`, { + method: "DELETE", + token, + }).catch(() => {}); + } + return modelTestResult(candidate, { + status: passed ? "pass" : "fail", + reason: passed ? "" : safeReason(test.json.msg || test.json.message || "Space model test failed."), + http_status: test.status, + model_uuid: modelUuid, + created, + }); +} + +function modelTestResult(candidate, details) { + return { + source: candidate.source, + model_uuid: details.model_uuid || candidate.uuid || "", + model_name: candidate.name, + status: details.status, + reason: details.reason || "", + http_status: details.http_status ?? null, + created: Boolean(details.created), + }; +} + +function existingCandidate(model, source) { + return { + source, + uuid: model.uuid, + name: model.name, + abilities: model.abilities || [], + context_length: model.context_length, + prefered_ranking: model.prefered_ranking, + }; +} + +function scannedCandidate(model) { + return { + source: "scanned-space", + uuid: "", + name: model.name || model.id, + abilities: model.abilities || [], + context_length: model.context_length, + prefered_ranking: model.prefered_ranking, + }; +} + +function uniqueCandidates(candidates) { + const seen = new Set(); + const result = []; + for (const candidate of candidates) { + const key = candidate.uuid ? `uuid:${candidate.uuid}` : `name:${candidate.name}`; + if (!candidate.name || seen.has(key)) continue; + seen.add(key); + result.push(candidate); + } + return result; +} + +function rankModels(models) { + return [...models].sort((left, right) => { + const leftRank = Number.isFinite(Number(left.prefered_ranking)) ? Number(left.prefered_ranking) : 9999; + const rightRank = Number.isFinite(Number(right.prefered_ranking)) ? Number(right.prefered_ranking) : 9999; + if (leftRank !== rightRank) return leftRank - rightRank; + return String(left.name || "").localeCompare(String(right.name || "")); + }); +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function safeReason(value) { + return redact(String(value || "")).slice(0, 1000); +} + +async function upsertEnvLocal(path, updates) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const seen = new Set(); + const next = lines.map((line) => { + const trimmed = line.trim(); + const equals = trimmed.indexOf("="); + if (equals <= 0 || trimmed.startsWith("#")) return line; + const key = trimmed.slice(0, equals).trim(); + if (!(key in updates)) return line; + seen.add(key); + return `${key}=${updates[key]}`; + }); + for (const [key, value] of Object.entries(updates)) { + if (!seen.has(key)) next.push(`${key}=${value}`); + } + await writeFile(path, `${next.filter((line, index) => line !== "" || index < next.length - 1).join("\n")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/ensure-qa-agent-runner-pipeline.mjs b/skills/scripts/e2e/ensure-qa-agent-runner-pipeline.mjs new file mode 100755 index 0000000..abc4324 --- /dev/null +++ b/skills/scripts/e2e/ensure-qa-agent-runner-pipeline.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + ensureEvidence, + evidencePaths, + loadEnvFiles, + resetAndAuthLocalUser, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const RUNNER_ID = "plugin:qa/agent-runner/default"; +const DEFAULT_PIPELINE_NAME = "Agent QA Deterministic Runner Debug Chat"; +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; +const caseId = "ensure-qa-agent-runner-pipeline"; + +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const writeEnv = process.argv.includes("--write-env"); +const frontendUrl = env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const pipelineName = env.LANGBOT_E2E_CREATE_PIPELINE_NAME || env.LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME || DEFAULT_PIPELINE_NAME; +const envLocalPath = resolve("skills/.env.local"); + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + frontend_url: frontendUrl, + backend_url: backendUrl, + pipeline_name: pipelineName, + pipeline_id: "", + pipeline_url: "", + runner_id: RUNNER_ID, + wrote_env: false, + auth: null, + evidence: { + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this setup can create/update the pipeline via backend API."); + } + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + result.auth = { + source: "local_recovery_login", + user, + backend_token_check: auth.check, + }; + + const prepared = await ensurePipeline({ + backendUrl, + token: auth.token, + pipelineName, + runnerId: RUNNER_ID, + runnerConfig: {}, + }); + Object.assign(result, prepared); + if (result.pipeline_id) { + result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; + } + + if (writeEnv && result.pipeline_id) { + await upsertEnvLocal(envLocalPath, { + LANGBOT_E2E_LOGIN_USER: user, + LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL: result.pipeline_url, + LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME: result.pipeline_name || pipelineName, + }); + result.wrote_env = true; + } +} catch (error) { + result.reason = result.reason || error.message; +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); + +async function ensurePipeline({ backendUrl, token, pipelineName, runnerId, runnerConfig }) { + const pipelineList = await apiJson(backendUrl, "/api/v1/pipelines", { token }); + if (isApiFailure(pipelineList)) { + return { + status: "fail", + reason: pipelineList.json.msg || "Failed to list pipelines.", + list_status: pipelineList.status, + }; + } + + const pipelines = pipelineList.json.data?.pipelines || []; + let pipeline = pipelines.find((item) => item.name === pipelineName) || null; + let created = false; + + if (!pipeline) { + const createdResponse = await apiJson(backendUrl, "/api/v1/pipelines", { + method: "POST", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for deterministic QA AgentRunner Debug Chat smoke tests.", + emoji: "QA", + }, + }); + if (isApiFailure(createdResponse)) { + return { + status: "fail", + reason: createdResponse.json.msg || "Failed to create pipeline.", + create_status: createdResponse.status, + }; + } + const pipelineId = createdResponse.json.data?.uuid || ""; + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { token }); + pipeline = loaded.json.data?.pipeline || null; + created = true; + } + + if (!pipeline?.uuid) { + return { + status: "fail", + reason: "Pipeline was not created or resolved.", + }; + } + + const loaded = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { token }); + if (isApiFailure(loaded) || !loaded.json.data?.pipeline) { + return { + status: "fail", + reason: loaded.json.msg || "Failed to load pipeline.", + get_status: loaded.status, + pipeline_id: pipeline.uuid, + }; + } + pipeline = loaded.json.data.pipeline; + + const config = pipeline.config && typeof pipeline.config === "object" ? pipeline.config : {}; + const ai = config.ai && typeof config.ai === "object" ? config.ai : {}; + const runnerConfigs = ai.runner_config && typeof ai.runner_config === "object" ? ai.runner_config : {}; + const updatedConfig = { + ...config, + ai: { + ...ai, + runner: { + ...(ai.runner && typeof ai.runner === "object" ? ai.runner : {}), + id: runnerId, + "expire-time": 0, + }, + runner_config: { + ...runnerConfigs, + [runnerId]: runnerConfig, + }, + }, + }; + + const updateResponse = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.uuid)}`, { + method: "PUT", + token, + body: { + name: pipelineName, + description: "Local QA pipeline for deterministic QA AgentRunner Debug Chat smoke tests.", + emoji: "QA", + config: updatedConfig, + }, + }); + if (isApiFailure(updateResponse)) { + return { + status: "fail", + reason: updateResponse.json.msg || "Failed to update pipeline.", + update_status: updateResponse.status, + pipeline_id: pipeline.uuid, + }; + } + + return { + status: "pass", + reason: created ? "QA AgentRunner pipeline created and configured." : "QA AgentRunner pipeline updated.", + pipeline_id: pipeline.uuid, + pipeline_name: pipelineName, + created, + updated: true, + }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json && response.json.code !== undefined && response.json.code !== 0); +} + +async function upsertEnvLocal(path, values) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + text = ""; + } + const lines = text.split(/\r?\n/); + const keys = new Set(Object.keys(values)); + const output = []; + for (const line of lines) { + const match = line.match(/^([A-Z][A-Z0-9_]*)=/); + if (match && keys.has(match[1])) { + output.push(`${match[1]}=${values[match[1]]}`); + keys.delete(match[1]); + } else if (line !== "" || output.length > 0) { + output.push(line); + } + } + if (keys.size > 0 && output.length > 0 && output[output.length - 1] !== "") { + output.push(""); + } + for (const key of keys) { + output.push(`${key}=${values[key]}`); + } + await writeFile(path, `${output.join("\n").replace(/\n+$/, "")}\n`, "utf8"); +} diff --git a/skills/scripts/e2e/fake-openai-provider.mjs b/skills/scripts/e2e/fake-openai-provider.mjs new file mode 100755 index 0000000..1cca9c4 --- /dev/null +++ b/skills/scripts/e2e/fake-openai-provider.mjs @@ -0,0 +1,496 @@ +#!/usr/bin/env node + +import { createServer } from "node:http"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { env, exit } from "node:process"; + +const args = parseArgs(process.argv.slice(2)); +const host = args.host || env.LANGBOT_FAKE_PROVIDER_HOST || "127.0.0.1"; +const port = integer(args.port ?? env.LANGBOT_FAKE_PROVIDER_PORT, 0); +const stateFile = args["state-file"] || env.LANGBOT_FAKE_PROVIDER_STATE_FILE || ""; +const modelName = env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || "gpt-4o-mini"; +const config = { + response_text: env.LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT || "OK", + first_token_delay_ms: integer(env.LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS, 25), + chunk_delay_ms: integer(env.LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS, 10), + chunk_count: integer(env.LANGBOT_FAKE_PROVIDER_CHUNK_COUNT, 0), + fault_status: integer(env.LANGBOT_FAKE_PROVIDER_FAULT_STATUS, 500), + fail_first_n: integer(env.LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N, 0), + fail_every_n: integer(env.LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N, 0), + fail_after_first_chunk: bool(env.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK, false), + dynamic_response: !/^(0|false|no|off)$/i.test(env.LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE || ""), + request_log_limit: integer(env.LANGBOT_FAKE_PROVIDER_REQUEST_LOG_LIMIT, 500), +}; + +let requestCount = 0; +const recentRequests = []; + +const server = createServer(async (request, response) => { + const startedAt = Date.now(); + const startedPerf = performance.now(); + let requestRecord = null; + const url = new URL(request.url || "/", `http://${request.headers.host || `${host}:${port}`}`); + try { + if (request.method === "GET" && url.pathname === "/healthz") { + sendJson(response, 200, { + ok: true, + model: modelName, + config, + request_count: requestCount, + recent_request_count: recentRequests.length, + }); + return; + } + + if (request.method === "GET" && url.pathname === "/__qa/config") { + sendJson(response, 200, { + ok: true, + model: modelName, + config, + request_count: requestCount, + recent_requests: recentRequests, + }); + return; + } + + if (request.method === "POST" && url.pathname === "/__qa/config") { + const body = await readJson(request); + applyConfig(body.config && typeof body.config === "object" ? body.config : body); + if (body.reset_request_count !== false) resetRequestState(); + sendJson(response, 200, { + ok: true, + model: modelName, + config, + request_count: requestCount, + }); + return; + } + + if (request.method === "POST" && url.pathname === "/__qa/reset") { + resetRequestState(); + sendJson(response, 200, { + ok: true, + model: modelName, + config, + request_count: requestCount, + }); + return; + } + + if (request.method === "GET" && ["/models", "/v1/models"].includes(url.pathname)) { + sendJson(response, 200, { + object: "list", + data: [ + { + id: modelName, + object: "model", + created: 1, + owned_by: "langbot-qa", + type: "llm", + }, + ], + }); + return; + } + + if (request.method === "POST" && ["/chat/completions", "/v1/chat/completions"].includes(url.pathname)) { + requestCount += 1; + const body = await readJson(request); + const requestId = `chatcmpl-langbot-fake-${requestCount}`; + const shouldFail = requestCount <= config.fail_first_n + || (config.fail_every_n > 0 && requestCount % config.fail_every_n === 0); + const replyText = responseTextForBody(body); + requestRecord = recordRequest({ + id: requestId, + request_number: requestCount, + path: url.pathname, + stream: Boolean(body.stream), + model: body.model || "", + message_count: Array.isArray(body.messages) ? body.messages.length : 0, + should_fail: shouldFail, + status: "running", + http_status: null, + expected_text: replyText, + response_text_preview: previewText(replyText), + started_at: new Date(startedAt).toISOString(), + started_epoch_ms: startedAt, + configured_first_token_delay_ms: config.first_token_delay_ms, + configured_chunk_delay_ms: config.chunk_delay_ms, + configured_chunk_count: config.chunk_count, + }); + + if (shouldFail) { + await sleep(config.first_token_delay_ms); + sendJson(response, config.fault_status, { + error: { + message: `LangBot fake provider injected HTTP ${config.fault_status}`, + type: "fake_provider_fault", + code: "fake_provider_fault", + }, + }); + finishRequestRecord(requestRecord, startedPerf, { + status: "http_fault", + http_status: config.fault_status, + }); + return; + } + + if (body.stream) { + await streamCompletion(response, { + requestId, + model: body.model || modelName, + content: replyText, + failAfterFirstChunk: config.fail_after_first_chunk, + requestRecord, + startedPerf, + }); + } else { + await sleep(config.first_token_delay_ms + config.chunk_delay_ms); + sendJson(response, 200, completionPayload({ + requestId, + model: body.model || modelName, + content: replyText, + })); + markRequestTiming(requestRecord, "first_chunk", startedPerf); + markRequestTiming(requestRecord, "first_content_chunk", startedPerf); + requestRecord.content_chunk_count = 1; + finishRequestRecord(requestRecord, startedPerf, { + status: "ok", + http_status: 200, + }); + } + return; + } + + sendJson(response, 404, { + error: { + message: `No fake provider route for ${request.method} ${url.pathname}`, + type: "not_found", + }, + }); + } catch (error) { + if (requestRecord) { + finishRequestRecord(requestRecord, startedPerf, { + status: "fake_provider_error", + http_status: 500, + error: error instanceof Error ? error.message : String(error), + }); + } + sendJson(response, 500, { + error: { + message: error instanceof Error ? error.message : String(error), + type: "fake_provider_error", + }, + }); + } finally { + const durationMs = Date.now() - startedAt; + if (url.pathname !== "/healthz") { + console.log(JSON.stringify({ + at: new Date().toISOString(), + method: request.method, + path: url.pathname, + duration_ms: durationMs, + })); + } + } +}); + +server.listen(port, host, async () => { + const address = server.address(); + const selectedPort = typeof address === "object" && address ? address.port : port; + const url = `http://${host}:${selectedPort}`; + const state = { + status: "ready", + pid: process.pid, + url, + base_url: `${url}/v1`, + model: modelName, + started_at: new Date().toISOString(), + }; + if (stateFile) { + const path = resolve(stateFile); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + } + console.log(JSON.stringify(state)); +}); + +server.on("error", (error) => { + console.error(JSON.stringify({ + status: "error", + reason: error instanceof Error ? error.message : String(error), + })); + exit(1); +}); + +process.on("SIGTERM", () => { + server.close(() => exit(0)); +}); + +function parseArgs(argv) { + const result = {}; + for (const item of argv) { + const match = item.match(/^--([^=]+)(?:=(.*))?$/); + if (!match) continue; + result[match[1]] = match[2] ?? "1"; + } + return result; +} + +function integer(value, fallback) { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function bool(value, fallback) { + if (value === undefined || value === "") return fallback; + if (/^(1|true|yes|on)$/i.test(String(value))) return true; + if (/^(0|false|no|off)$/i.test(String(value))) return false; + return fallback; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); +} + +async function readJson(request) { + let text = ""; + for await (const chunk of request) text += chunk.toString(); + if (!text) return {}; + return JSON.parse(text); +} + +function sendJson(response, status, payload) { + const text = `${JSON.stringify(payload)}\n`; + response.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(text), + }); + response.end(text); +} + +function completionPayload({ requestId, model, content }) { + const completionTokens = tokenEstimate(content); + return { + id: requestId, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content, + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 8, + completion_tokens: completionTokens, + total_tokens: 8 + completionTokens, + }, + }; +} + +async function streamCompletion(response, { + requestId, + model, + content, + failAfterFirstChunk: failMidStream, + requestRecord, + startedPerf, +}) { + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + "connection": "keep-alive", + }); + + await sleep(config.first_token_delay_ms); + markRequestTiming(requestRecord, "first_chunk", startedPerf); + writeSse(response, { + id: requestId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + + const chunks = splitContent(content); + for (let index = 0; index < chunks.length; index += 1) { + await sleep(config.chunk_delay_ms); + if (index === 0) markRequestTiming(requestRecord, "first_content_chunk", startedPerf); + requestRecord.content_chunk_count = (requestRecord.content_chunk_count || 0) + 1; + writeSse(response, { + id: requestId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: { content: chunks[index] }, finish_reason: null }], + }); + if (failMidStream && index === 0) { + finishRequestRecord(requestRecord, startedPerf, { + status: "mid_stream_disconnect", + http_status: 200, + }); + response.destroy(new Error("LangBot fake provider injected mid-stream disconnect")); + return; + } + } + + await sleep(config.chunk_delay_ms); + const completionTokens = tokenEstimate(content); + writeSse(response, { + id: requestId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 8, + completion_tokens: completionTokens, + total_tokens: 8 + completionTokens, + }, + }); + response.write("data: [DONE]\n\n"); + response.end(); + finishRequestRecord(requestRecord, startedPerf, { + status: "ok", + http_status: 200, + }); +} + +function writeSse(response, payload) { + response.write(`data: ${JSON.stringify(payload)}\n\n`); +} + +function splitContent(content) { + const text = String(content); + const requested = config.chunk_count; + if (requested <= 1 || text.length <= 1) return [text]; + const chunkSize = Math.max(1, Math.ceil(text.length / requested)); + const chunks = []; + for (let index = 0; index < text.length; index += chunkSize) { + chunks.push(text.slice(index, index + chunkSize)); + } + return chunks; +} + +function tokenEstimate(content) { + return Math.max(1, Math.ceil(String(content || "").length / 4)); +} + +function responseTextForBody(body) { + if (!config.dynamic_response) { + return config.response_text; + } + const messages = Array.isArray(body.messages) ? body.messages : []; + const lastUser = [...messages].reverse().find((message) => message?.role === "user"); + const text = flattenContent(lastUser?.content || ""); + const quoted = text.match(/["'“”](.{1,80}?)["'“”]/); + if (quoted?.[1]) return quoted[1].trim(); + const exact = text.match(/(?:reply|回复|输出|return)\s+(?:exactly\s+)?([A-Za-z0-9_.:@-]{1,80})/i); + if (exact?.[1]) return exact[1].trim().replace(/[。.!?]+$/, ""); + const only = text.match(/只回复\s*([A-Za-z0-9_.:@-]{1,80})/); + if (only?.[1]) return only[1].trim().replace(/[。.!?]+$/, ""); + return config.response_text; +} + +function flattenContent(content) { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((item) => { + if (typeof item === "string") return item; + if (item && typeof item === "object") return item.text || ""; + return ""; + }) + .join("\n"); + } + return ""; +} + +function recordRequest(entry) { + const item = { + ...entry, + at: new Date().toISOString(), + finished_at: null, + finished_epoch_ms: null, + duration_ms: null, + first_chunk_at: null, + first_chunk_epoch_ms: null, + first_chunk_ms: null, + first_content_chunk_at: null, + first_content_chunk_epoch_ms: null, + first_content_chunk_ms: null, + content_chunk_count: 0, + }; + recentRequests.push(item); + while (recentRequests.length > config.request_log_limit) recentRequests.shift(); + return item; +} + +function markRequestTiming(entry, key, startedPerf) { + if (!entry || entry[`${key}_at`]) return; + const now = Date.now(); + entry[`${key}_at`] = new Date(now).toISOString(); + entry[`${key}_epoch_ms`] = now; + entry[`${key}_ms`] = rounded(performance.now() - startedPerf); +} + +function finishRequestRecord(entry, startedPerf, updates = {}) { + if (!entry || entry.finished_at) return; + const now = Date.now(); + Object.assign(entry, updates); + entry.finished_at = new Date(now).toISOString(); + entry.finished_epoch_ms = now; + entry.duration_ms = rounded(performance.now() - startedPerf); +} + +function rounded(value) { + return Number(value.toFixed(3)); +} + +function previewText(value) { + return String(value || "").slice(0, 120); +} + +function resetRequestState() { + requestCount = 0; + recentRequests.length = 0; +} + +function applyConfig(updates) { + if (!updates || typeof updates !== "object") return; + assignString(updates, "response_text"); + assignNonNegativeInteger(updates, "first_token_delay_ms"); + assignNonNegativeInteger(updates, "chunk_delay_ms"); + assignNonNegativeInteger(updates, "chunk_count"); + assignNonNegativeInteger(updates, "fail_first_n"); + assignNonNegativeInteger(updates, "fail_every_n"); + assignNonNegativeInteger(updates, "request_log_limit"); + if (updates.fault_status !== undefined) { + const parsed = Number.parseInt(String(updates.fault_status), 10); + if (Number.isInteger(parsed) && parsed >= 400 && parsed <= 599) config.fault_status = parsed; + } + assignBoolean(updates, "fail_after_first_chunk"); + assignBoolean(updates, "dynamic_response"); +} + +function assignString(updates, key) { + if (updates[key] !== undefined) config[key] = String(updates[key]); +} + +function assignNonNegativeInteger(updates, key) { + if (updates[key] === undefined) return; + const parsed = Number.parseInt(String(updates[key]), 10); + if (Number.isInteger(parsed) && parsed >= 0) config[key] = parsed; +} + +function assignBoolean(updates, key) { + if (updates[key] === undefined) return; + config[key] = bool(updates[key], config[key]); +} diff --git a/skills/scripts/e2e/install-qa-plugin-smoke.mjs b/skills/scripts/e2e/install-qa-plugin-smoke.mjs new file mode 100755 index 0000000..73b89af --- /dev/null +++ b/skills/scripts/e2e/install-qa-plugin-smoke.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + apiJson, + ensureEvidence, + evidencePaths, + loadEnvFiles, + resetAndAuthLocalUser, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = env.LBS_CASE_ID || "install-qa-plugin-smoke"; +const paths = evidencePaths(caseId); +await loadEnvFiles(); +await ensureEvidence(paths); + +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const user = env.LANGBOT_E2E_LOGIN_USER || ""; +const password = env.LANGBOT_E2E_LOGIN_PASSWORD || "LangBotE2ELocalPass!2026"; +const packagePath = resolve( + env.LANGBOT_E2E_PLUGIN_PACKAGE + || env.LANGBOT_QA_PLUGIN_SMOKE_PACKAGE + || "skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg", +); +const expectedPluginId = env.LANGBOT_E2E_EXPECTED_PLUGIN_ID || "qa/plugin-smoke"; +const expectedTool = env.LANGBOT_E2E_EXPECTED_TOOL || (expectedPluginId === "qa/plugin-smoke" ? "qa_plugin_echo" : ""); +const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || ""; + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + backend_url: backendUrl, + package_path: packagePath, + package_preview: null, + task_id: null, + task: null, + plugin_present_before: false, + plugin_present_after: false, + tool_names: [], + runner_ids: [], + evidence: { + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic", "filesystem"], +}; + +try { + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + if (!user) throw new Error("LANGBOT_E2E_LOGIN_USER is required."); + const bytes = await readFile(packagePath); + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + result.package_preview = await previewPackage(backendUrl, auth.token, bytes, packagePath); + const metadata = result.package_preview.metadata || {}; + if (`${metadata.author}/${metadata.name}` !== expectedPluginId) { + throw new Error(`Fixture package metadata is ${metadata.author}/${metadata.name}, expected ${expectedPluginId}.`); + } + result.plugin_present_before = await hasPlugin(backendUrl, auth.token); + + if (!result.plugin_present_before) { + const form = new FormData(); + form.set("file", new Blob([bytes]), packagePath.split("/").pop()); + const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local`, { + method: "POST", + headers: { Authorization: `Bearer ${auth.token}` }, + body: form, + }); + const json = await response.json().catch(() => ({})); + if (response.status >= 400 || json.code !== 0) { + throw new Error(json.msg || `Plugin install request failed with HTTP ${response.status}.`); + } + result.task_id = json.data?.task_id ?? null; + if (!result.task_id) throw new Error("Plugin install response did not include task_id."); + result.task = await waitForTask(backendUrl, auth.token, result.task_id); + if (!isTaskComplete(result.task)) { + throw new Error(`Plugin install task did not complete successfully: ${JSON.stringify(result.task)}`); + } + } + + await sleep(1000); + result.plugin_present_after = await hasPlugin(backendUrl, auth.token); + if (!result.plugin_present_after) throw new Error(`${expectedPluginId} is not listed by /api/v1/plugins after install.`); + if (expectedTool) { + result.tool_names = await listToolNames(backendUrl, auth.token); + if (!result.tool_names.includes(expectedTool)) { + throw new Error(`${expectedTool} is not listed by /api/v1/tools after install.`); + } + } + if (expectedRunnerId) { + result.runner_ids = await listRunnerIds(backendUrl, auth.token); + if (!result.runner_ids.includes(expectedRunnerId)) { + throw new Error(`${expectedRunnerId} is not listed by /api/v1/pipelines/_/metadata after install.`); + } + } + + result.status = "pass"; + result.reason = `${expectedPluginId} is installed.`; +} catch (error) { + result.status = "fail"; + result.reason = error.message; +} finally { + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : 1); + +async function hasPlugin(backendUrl, token) { + const response = await apiJson(backendUrl, "/api/v1/plugins", { token }); + const plugins = response.json.data?.plugins || []; + return plugins.some((plugin) => { + const metadata = plugin.manifest?.manifest?.metadata || plugin.manifest?.metadata || plugin.metadata || {}; + return `${metadata.author}/${metadata.name}` === expectedPluginId; + }); +} + +async function previewPackage(backendUrl, token, bytes, packagePath) { + const form = new FormData(); + form.set("file", new Blob([bytes]), packagePath.split("/").pop()); + const response = await fetch(`${backendUrl.replace(/\/$/, "")}/api/v1/plugins/install/local/preview`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: form, + }); + const json = await response.json().catch(() => ({})); + if (response.status >= 400 || json.code !== 0) { + throw new Error(json.msg || `Plugin package preview failed with HTTP ${response.status}.`); + } + return { + metadata: json.data?.metadata || {}, + component_types: json.data?.component_types || [], + file_count: json.data?.file_count ?? null, + }; +} + +async function listToolNames(backendUrl, token) { + const response = await apiJson(backendUrl, "/api/v1/tools", { token }); + return (response.json.data?.tools || []) + .map((tool) => tool.name || tool.tool_name || tool.function?.name || "") + .filter(Boolean) + .sort(); +} + +async function listRunnerIds(backendUrl, token) { + const response = await apiJson(backendUrl, "/api/v1/pipelines/_/metadata", { token }); + const configs = response.json.data?.configs || []; + return configs + .flatMap((section) => section.stages || []) + .flatMap((stage) => stage.config || []) + .filter((item) => item.name === "id") + .flatMap((item) => item.options || []) + .map((option) => option.name || option.value || option.id || "") + .filter(Boolean) + .sort(); +} + +async function waitForTask(backendUrl, token, taskId) { + const deadline = Date.now() + Number(env.LANGBOT_PLUGIN_INSTALL_TIMEOUT_MS || 120000); + let last = null; + while (Date.now() < deadline) { + const response = await apiJson(backendUrl, `/api/v1/system/tasks/${encodeURIComponent(taskId)}`, { token }); + last = response.json.data || response.json; + if (isTaskComplete(last) || isTaskFailed(last)) return last; + await sleep(1000); + } + return last; +} + +function isTaskComplete(task) { + const status = String(task?.status || task?.state || "").toLowerCase(); + const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase(); + return ["done", "completed", "success", "succeeded", "finished"].includes(status) + || ["done", "completed", "success", "succeeded", "finished"].includes(runtimeStatus) + || task?.done === true + || task?.completed === true + || (task?.runtime?.done === true && !task?.runtime?.exception); +} + +function isTaskFailed(task) { + const status = String(task?.status || task?.state || "").toLowerCase(); + const runtimeStatus = String(task?.runtime?.status || task?.runtime?.state || "").toLowerCase(); + return ["failed", "error", "cancelled", "canceled"].includes(status) + || ["failed", "error", "cancelled", "canceled"].includes(runtimeStatus) + || task?.failed === true + || Boolean(task?.error) + || Boolean(task?.runtime?.exception); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/skills/scripts/e2e/langrag-kb-retrieve.mjs b/skills/scripts/e2e/langrag-kb-retrieve.mjs new file mode 100755 index 0000000..a2b489a --- /dev/null +++ b/skills/scripts/e2e/langrag-kb-retrieve.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node + +import { + bodyText, + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + isLoginUrl, + localIsoWithOffset, + safeScreenshot, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = process.env.LBS_CASE_ID || "langrag-kb-retrieve"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; +const kbUuid = process.env.LANGBOT_LOCAL_AGENT_RAG_KB_UUID || process.env.LANGBOT_RAG_KB_UUID || ""; +const query = process.env.LANGBOT_E2E_RETRIEVE_QUERY || "What is the local agent runner retrieval sentinel?"; +const expectedText = process.env.LANGBOT_E2E_EXPECTED_TEXT || "azalea-cobalt-7421"; + +let browser; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + kb_uuid: kbUuid, + query, + expected_text: expectedText, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "network", "api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + if (!kbUuid) throw new Error("LANGBOT_LOCAL_AGENT_RAG_KB_UUID or LANGBOT_RAG_KB_UUID is required."); + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(`${frontendUrl.replace(/\/$/, "")}/home/knowledge`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + result.url = page.url(); + + const text = await bodyText(page); + if (isLoginUrl(page.url()) || /登录|Login|Sign in/i.test(text)) { + result.status = "blocked"; + result.reason = "Browser profile is not authenticated for LANGBOT_FRONTEND_URL."; + } else if (!/Knowledge|知识库|qa-local-agent-rag/i.test(text)) { + result.status = "fail"; + result.reason = "Knowledge page opened, but no Knowledge UI signal or QA KB name was visible."; + } else { + const retrieve = await page.evaluate(async ({ backendUrl, kbUuid, query }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { status: "blocked", authenticated: false, reason: "Browser profile has no localStorage token." }; + } + const response = await fetch(`${backendUrl}/api/v1/knowledge/bases/${encodeURIComponent(kbUuid)}/retrieve`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), + }); + const json = await response.json().catch(() => ({})); + return { + status: response.status >= 400 ? "fail" : "ready", + authenticated: true, + http_status: response.status, + code: json.code ?? null, + msg: json.msg || "", + results: json.data?.results || [], + }; + }, { backendUrl, kbUuid, query }); + + result.retrieve = { + ...retrieve, + results: Array.isArray(retrieve.results) + ? retrieve.results.map((item) => ({ + score: item.score ?? item.distance ?? null, + text: String(item.text || item.content || "").slice(0, 500), + metadata: item.metadata || {}, + })) + : [], + }; + + const resultText = JSON.stringify(result.retrieve.results || []); + if (retrieve.status === "blocked") { + result.status = "blocked"; + result.reason = retrieve.reason || "Retrieve API blocked."; + } else if (retrieve.status === "fail") { + result.status = "fail"; + result.reason = retrieve.msg || "Retrieve API failed."; + } else if (!resultText.includes(expectedText)) { + result.status = "fail"; + result.reason = `Retrieve results did not contain expected text: ${expectedText}`; + } else { + result.status = "pass"; + result.reason = `Knowledge retrieve returned expected sentinel: ${expectedText}`; + } + } + + await safeScreenshot(page, paths.screenshot); +} catch (error) { + result.status = /Playwright is not installed|not configured|required/.test(error.message) ? "env_issue" : "fail"; + result.reason = error.message; +} finally { + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/lib/debug-chat.mjs b/skills/scripts/e2e/lib/debug-chat.mjs new file mode 100755 index 0000000..7d44d00 --- /dev/null +++ b/skills/scripts/e2e/lib/debug-chat.mjs @@ -0,0 +1,416 @@ +import { + bodyText, + clickFirstVisible, + countOccurrences, + gotoFrontend, + isLoginUrl, +} from "./langbot-e2e.mjs"; + +export const DEBUG_CHAT_FAILURE_SIGNALS = [ + "Agent runner temporarily unavailable", + "All models failed during streaming setup", + "调用超时", + "超时", +]; + +export function minExpectedOccurrences(beforeText, expectedText, prompt) { + const beforeCount = countOccurrences(beforeText, expectedText); + return beforeCount + (String(prompt).includes(expectedText) ? 2 : 1); +} + +export function latestExpectedLeafMatches(latestExpectedLeaf, prompt) { + return Boolean(latestExpectedLeaf) + && latestExpectedLeaf !== prompt + && !String(latestExpectedLeaf).includes(prompt); +} + +export function findNewFailureSignal(beforeText, afterText, failureSignals = DEBUG_CHAT_FAILURE_SIGNALS) { + return failureSignals.find((signal) => countOccurrences(afterText, signal) > countOccurrences(beforeText, signal)) || ""; +} + +function findFailureSignalInText(text, failureSignals = DEBUG_CHAT_FAILURE_SIGNALS) { + return failureSignals.find((signal) => String(text || "").includes(signal)) || ""; +} + +function countExpectedInMessages(messages, expectedText) { + return messages + .filter((message) => message.role === "assistant") + .reduce((count, message) => count + countOccurrences(message.text, expectedText), 0); +} + +function debugChatInput(page) { + return page + .locator('input[placeholder*="message"], input[placeholder*="消息"], textarea[placeholder*="message"], textarea[placeholder*="消息"]') + .last(); +} + +async function clickDebugChatTab(page) { + const tabByRole = page.getByRole("tab", { name: /Debug Chat|调试聊天|调试对话|Debug|调试/i }).first(); + if (await tabByRole.isVisible({ timeout: 3_000 }).catch(() => false)) { + await tabByRole.click(); + return true; + } + + const tabBySelector = page.locator('[role="tab"]').filter({ hasText: /Debug Chat|调试聊天|调试对话|Debug|调试/i }).first(); + if (await tabBySelector.isVisible({ timeout: 2_000 }).catch(() => false)) { + await tabBySelector.click(); + return true; + } + + return Boolean(await clickFirstVisible(page, ["Debug Chat", "调试聊天", "调试对话"], 2_000)); +} + +async function waitForDebugChatReady(page, timeout = 20_000) { + const input = debugChatInput(page); + const visible = await input.isVisible({ timeout }).catch(() => false); + if (!visible) { + return { + ready: false, + reason: "Debug Chat tab was clicked, but the Debug Chat input did not become visible.", + }; + } + + const enabled = await input.isEnabled({ timeout }).catch(() => false); + if (!enabled) { + return { + ready: false, + reason: "Debug Chat input is visible but disabled; WebSocket may not be connected.", + }; + } + + return { ready: true, reason: "" }; +} + +export function classifyDebugChatResult({ + beforeText, + afterText, + expectedText, + prompt, + latestExpectedLeaf, + latestFailureLeaf, + beforeMessages = null, + afterMessages = null, + latestAssistantText = "", + failureSignals = DEBUG_CHAT_FAILURE_SIGNALS, +}) { + const minExpectedCount = minExpectedOccurrences(beforeText, expectedText, prompt); + const finalCount = countOccurrences(afterText, expectedText); + const failureText = findNewFailureSignal(beforeText, afterText, failureSignals); + const promptContainsExpected = String(prompt).includes(expectedText); + const hasMessageEvidence = Array.isArray(beforeMessages) && Array.isArray(afterMessages); + const beforeAssistantExpectedCount = hasMessageEvidence + ? countExpectedInMessages(beforeMessages, expectedText) + : null; + const afterAssistantExpectedCount = hasMessageEvidence + ? countExpectedInMessages(afterMessages, expectedText) + : null; + const assistantExpectedIncreased = hasMessageEvidence + ? afterAssistantExpectedCount > beforeAssistantExpectedCount + : false; + + if (hasMessageEvidence) { + const latestAssistantFailure = findFailureSignalInText(latestAssistantText, failureSignals); + if (latestAssistantFailure) { + return { + status: "fail", + reason: `Debug Chat displayed a known failure signal in the latest assistant message: ${latestAssistantFailure}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + failure_signal: latestAssistantFailure, + before_assistant_expected_count: beforeAssistantExpectedCount, + after_assistant_expected_count: afterAssistantExpectedCount, + }; + } + if (assistantExpectedIncreased && String(latestAssistantText).includes(expectedText)) { + return { + status: "pass", + reason: `Expected text appeared in a new assistant message: ${expectedText}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + before_assistant_expected_count: beforeAssistantExpectedCount, + after_assistant_expected_count: afterAssistantExpectedCount, + }; + } + if (failureText) { + return { + status: "fail", + reason: `Debug Chat displayed a known failure signal: ${failureText}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + failure_signal: failureText, + before_assistant_expected_count: beforeAssistantExpectedCount, + after_assistant_expected_count: afterAssistantExpectedCount, + }; + } + return { + status: "fail", + reason: `Expected text did not appear in a new assistant message. Expected assistant occurrences to increase above ${beforeAssistantExpectedCount}, saw ${afterAssistantExpectedCount}.`, + min_expected_count: minExpectedCount, + final_count: finalCount, + before_assistant_expected_count: beforeAssistantExpectedCount, + after_assistant_expected_count: afterAssistantExpectedCount, + }; + } + if (failureText) { + return { + status: "fail", + reason: `Debug Chat displayed a known failure signal: ${failureText}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + failure_signal: failureText, + before_assistant_expected_count: beforeAssistantExpectedCount, + after_assistant_expected_count: afterAssistantExpectedCount, + }; + } + if (latestExpectedLeafMatches(latestExpectedLeaf, prompt) && finalCount >= minExpectedCount) { + return { + status: "pass", + reason: `Expected text appeared in the latest visible response leaf: ${expectedText}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + }; + } + if (!promptContainsExpected && finalCount >= minExpectedCount) { + return { + status: "pass", + reason: `Expected text appeared enough times for user prompt plus bot response: ${expectedText}`, + min_expected_count: minExpectedCount, + final_count: finalCount, + }; + } + return { + status: "fail", + reason: `Bot response did not appear. Expected ${minExpectedCount} occurrences of ${expectedText}, saw ${finalCount}.`, + min_expected_count: minExpectedCount, + final_count: finalCount, + }; +} + +export async function openPipelineDebugChat(page, { pipelineUrl, pipelineName, envHint = "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME" }) { + if (pipelineUrl) { + await page.goto(pipelineUrl, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + } else { + if (!pipelineName) { + return { + opened: false, + status: "blocked", + reason: `Set ${envHint} before running pipeline-debug-chat automation.`, + }; + } + await gotoFrontend(page); + if (isLoginUrl(page.url())) { + return { + opened: false, + status: "blocked", + reason: "Browser profile is not authenticated for LANGBOT_FRONTEND_URL.", + }; + } + const clickedPipelines = await clickFirstVisible(page, ["Pipelines", "流水线"], 4_000); + if (!clickedPipelines) { + return { opened: false, status: "fail", reason: "Could not find Pipelines navigation." }; + } + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const clickedPipeline = await clickFirstVisible(page, [pipelineName], 5_000); + if (!clickedPipeline) { + return { opened: false, status: "blocked", reason: `Could not find pipeline named ${pipelineName}.` }; + } + } + + if (isLoginUrl(page.url())) { + return { + opened: false, + status: "blocked", + reason: "Browser profile is not authenticated for LANGBOT_FRONTEND_URL.", + }; + } + + const clickedDebug = await clickDebugChatTab(page); + if (!clickedDebug) { + return { opened: false, status: "fail", reason: "Could not find the Debug Chat tab." }; + } + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const ready = await waitForDebugChatReady(page); + if (!ready.ready) { + return { opened: false, status: "fail", reason: ready.reason }; + } + return { opened: true }; +} + +export async function latestVisibleLeafText(page, needles) { + return await page.evaluate((items) => { + const isVisible = (element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" + && style.display !== "none" + && rect.width > 0 + && rect.height > 0; + }; + const leaves = []; + for (const element of document.body.querySelectorAll("*")) { + if (!isVisible(element)) continue; + const text = element.innerText?.trim(); + if (!text || text.length > 4000) continue; + const visibleChildHasText = Array.from(element.children).some((child) => ( + isVisible(child) && child.innerText?.trim() + )); + if (visibleChildHasText) continue; + if (!items.some((needle) => text.includes(needle))) continue; + leaves.push(text); + } + return leaves.at(-1) || ""; + }, needles); +} + +export async function visibleDebugChatMessages(page) { + return await page.evaluate(() => { + const isVisible = (element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" + && style.display !== "none" + && rect.width > 0 + && rect.height > 0; + }; + const classText = (element) => String(element.getAttribute("class") || ""); + return Array.from(document.querySelectorAll("div.max-w-3xl")) + .filter((element) => isVisible(element)) + .map((element) => { + const row = element.parentElement; + const text = element.innerText?.trim() || ""; + const isUser = classText(element).includes("user-message-bubble") + || classText(row).includes("justify-end"); + return { + role: isUser ? "user" : "assistant", + text, + }; + }) + .filter((message) => message.text); + }); +} + +export async function waitForExpectedDebugChatText(page, { expectedText, minExpectedCount, timeoutMs }) { + await page.waitForFunction( + ({ expected, min }) => { + return document.body.innerText.split(expected).length - 1 >= min; + }, + { expected: expectedText, min: minExpectedCount }, + { timeout: timeoutMs }, + ).catch(() => {}); +} + +export async function waitForDebugChatTextStable(page, { timeoutMs = 5_000, quietMs = 750 } = {}) { + const startedAt = Date.now(); + let lastText = await bodyText(page); + let stableSince = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + await page.waitForTimeout(250); + const currentText = await bodyText(page); + if (currentText !== lastText) { + lastText = currentText; + stableSince = Date.now(); + continue; + } + if (Date.now() - stableSince >= quietMs) return; + } +} + +export async function attachDebugChatImage(page, imagePath) { + if (!imagePath) return { status: "not_required", reason: "" }; + const input = page.locator('input[type="file"][accept*="image"], input[type="file"]').first(); + if (!await input.count()) { + return { status: "fail", reason: "Could not find a Debug Chat image upload input." }; + } + await input.setInputFiles(imagePath); + await page.locator("img").last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {}); + return { status: "ready", reason: `Attached image fixture: ${imagePath}` }; +} + +export async function sendDebugChatPrompt(page, prompt, imagePath = "") { + const imageResult = await attachDebugChatImage(page, imagePath); + if (imageResult.status === "fail") return imageResult; + + const input = debugChatInput(page); + const inputVisible = await input.isVisible({ timeout: 5_000 }).catch(() => false); + const inputEnabled = inputVisible && await input.isEnabled({ timeout: 10_000 }).catch(() => false); + if (!inputVisible || !inputEnabled) return false; + await input.fill(prompt).catch(async () => { + await input.click(); + await input.pressSequentially(prompt); + }); + const clickedSend = await clickFirstVisible(page, ["Send", "发送", "提交"], 1_500); + if (!clickedSend) await page.keyboard.press("Enter"); + await page.getByText(prompt, { exact: false }).last().waitFor({ state: "visible", timeout: 10_000 }).catch(() => {}); + return true; +} + +export async function runDebugChatPrompt(page, { prompt, expectedText, responseTimeoutMs, imagePath = "", failureSignals = DEBUG_CHAT_FAILURE_SIGNALS }) { + const beforeText = await bodyText(page); + const beforeMessages = await visibleDebugChatMessages(page); + const minExpectedCount = minExpectedOccurrences(beforeText, expectedText, prompt); + const sent = await sendDebugChatPrompt(page, prompt, imagePath); + if (sent !== true) { + if (sent && typeof sent === "object" && typeof sent.reason === "string") return sent; + return { status: "fail", reason: "Could not find a Debug Chat text input." }; + } + + await waitForExpectedDebugChatText(page, { + expectedText, + minExpectedCount, + prompt, + timeoutMs: responseTimeoutMs, + }); + await waitForDebugChatTextStable(page); + + const afterText = await bodyText(page); + const afterMessages = await visibleDebugChatMessages(page); + const latestAssistantText = afterMessages.filter((message) => message.role === "assistant").at(-1)?.text || ""; + const latestExpectedLeaf = await latestVisibleLeafText(page, [expectedText]); + const failureText = findNewFailureSignal(beforeText, afterText, failureSignals); + const latestFailureLeaf = failureText ? await latestVisibleLeafText(page, [failureText]) : ""; + + return classifyDebugChatResult({ + beforeText, + afterText, + expectedText, + prompt, + latestExpectedLeaf, + latestFailureLeaf, + beforeMessages, + afterMessages, + latestAssistantText, + failureSignals, + }); +} + +export async function setDebugChatStreamOutput(page, desired) { + if (desired === null || desired === undefined) return { status: "not_required", reason: "" }; + + const streamSwitch = page.locator('[role="switch"]').first(); + if (!await streamSwitch.isVisible({ timeout: 5_000 }).catch(() => false)) { + return { status: "blocked", reason: "Debug Chat stream switch was not visible." }; + } + if (!await streamSwitch.isEnabled({ timeout: 10_000 }).catch(() => false)) { + return { status: "blocked", reason: "Debug Chat stream switch was visible but disabled." }; + } + + const checked = (await streamSwitch.getAttribute("aria-checked").catch(() => null)) === "true"; + if (checked !== desired) { + await streamSwitch.click(); + await page.waitForFunction( + ({ selector, expected }) => document.querySelector(selector)?.getAttribute("aria-checked") === String(expected), + { selector: '[role="switch"]', expected: desired }, + { timeout: 5_000 }, + ).catch(() => {}); + } + + const finalChecked = (await streamSwitch.getAttribute("aria-checked").catch(() => null)) === "true"; + if (finalChecked !== desired) { + return { + status: "fail", + reason: `Debug Chat stream switch did not reach requested state: ${desired ? "on" : "off"}.`, + }; + } + return { status: "ready", reason: `Debug Chat stream switch is ${desired ? "on" : "off"}.` }; +} diff --git a/skills/scripts/e2e/lib/langbot-e2e.mjs b/skills/scripts/e2e/lib/langbot-e2e.mjs new file mode 100755 index 0000000..a7584c9 --- /dev/null +++ b/skills/scripts/e2e/lib/langbot-e2e.mjs @@ -0,0 +1,342 @@ +import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env } from "node:process"; + +const secretRe = /(?:authorization|bearer|token|secret|password|api[_-]?key|jwt|oauth)\s*[:=]\s*["']?[^"',\s]+/gi; + +export function redact(text) { + return String(text ?? "") + .replace(secretRe, (match) => match.replace(/[:=]\s*["']?.*$/, "=[redacted]")) + .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]") + .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]"); +} + +export function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +export function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + const yyyy = date.getFullYear(); + const mm = pad(date.getMonth() + 1); + const dd = pad(date.getDate()); + const hh = pad(date.getHours()); + const mi = pad(date.getMinutes()); + const ss = pad(date.getSeconds()); + const ms = String(date.getMilliseconds()).padStart(3, "0"); + return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}.${ms}${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`; +} + +export function evidencePaths(caseId) { + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join("reports", "evidence", runId)); + return { + runId, + evidenceDir, + consoleLog: join(evidenceDir, "console.log"), + networkLog: join(evidenceDir, "network.log"), + screenshot: join(evidenceDir, "screenshot.png"), + automationResultJson: join(evidenceDir, "automation-result.json"), + resultJson: join(evidenceDir, "result.json"), + }; +} + +export async function ensureEvidence(paths) { + await mkdir(paths.evidenceDir, { recursive: true }); + await appendFile(paths.consoleLog, "", "utf8"); + await appendFile(paths.networkLog, "", "utf8"); +} + +export async function pathExists(path) { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +export async function appendLine(path, line) { + await appendFile(path, `[${localIsoWithOffset()}] ${redact(line)}\n`, "utf8"); +} + +export async function writeResult(paths, result) { + const text = `${JSON.stringify(result, null, 2)}\n`; + if (paths.automationResultJson) await writeFile(paths.automationResultJson, text, "utf8"); + if (paths.resultJson && paths.resultJson !== paths.automationResultJson) { + await writeFile(paths.resultJson, text, "utf8"); + } +} + +export async function loadEnvFiles(paths = ["skills/.env", "skills/.env.local"]) { + const processEnvKeys = new Set(Object.keys(env)); + for (const path of paths) { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch { + continue; + } + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const equals = trimmed.indexOf("="); + if (equals <= 0) continue; + const key = trimmed.slice(0, equals).trim(); + const value = trimmed.slice(equals + 1).trim().replace(/^["']|["']$/g, ""); + if (!processEnvKeys.has(key)) env[key] = value; + } + } +} + +export async function readRecoveryKey(repo = env.LANGBOT_REPO || "../LangBot") { + const configPath = resolve(repo, "data/config.yaml"); + const config = await readFile(configPath, "utf8"); + const match = config.match(/^\s*recovery_key:\s*['"]?([^'"\s#]+)['"]?\s*$/m); + return match?.[1] || ""; +} + +export async function apiJson(backendUrl, path, { method = "GET", token = "", body } = {}) { + const headers = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(`${backendUrl.replace(/\/$/, "")}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; +} + +export async function checkBackendToken(backendUrl, token) { + if (!token) { + return { authenticated: false, http_status: 0, code: null, reason: "No token." }; + } + const response = await apiJson(backendUrl, "/api/v1/user/check-token", { token }); + const code = response.json.code ?? null; + const authenticated = response.status < 400 && code === 0; + return { + authenticated, + http_status: response.status, + code, + reason: authenticated ? "Token accepted by backend." : response.json.msg || "Backend rejected token.", + }; +} + +export async function resetAndAuthLocalUser({ backendUrl, user, password, recoveryKey = "" }) { + const key = recoveryKey || await readRecoveryKey(); + if (!key) throw new Error("Could not read recovery_key from LangBot config."); + + const reset = await apiJson(backendUrl, "/api/v1/user/reset-password", { + method: "POST", + body: { + user, + recovery_key: key, + new_password: password, + }, + }); + if (reset.status >= 400 || reset.json.code !== 0) { + throw new Error(reset.json.msg || `Password reset failed with HTTP ${reset.status}.`); + } + + const auth = await apiJson(backendUrl, "/api/v1/user/auth", { + method: "POST", + body: { user, password }, + }); + const token = auth.json.data?.token || ""; + if (auth.status >= 400 || auth.json.code !== 0 || !token) { + throw new Error(auth.json.msg || `Auth failed with HTTP ${auth.status}.`); + } + + const check = await checkBackendToken(backendUrl, token); + if (!check.authenticated) { + throw new Error(check.reason || "Authenticated token failed backend token check."); + } + + return { token, check }; +} + +export async function setBrowserToken(page, frontendUrl, token) { + await page.addInitScript((value) => { + localStorage.setItem("token", value); + }, token); + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + await page.evaluate((value) => localStorage.setItem("token", value), token); +} + +export async function verifyBrowserToken(page, backendUrl) { + return await page.evaluate(async (baseUrl) => { + const token = localStorage.getItem("token"); + if (!token) { + return { authenticated: false, http_status: 0, code: null, reason: "No localStorage token." }; + } + try { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v1/user/check-token`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + const json = await response.json().catch(() => ({})); + const code = json.code ?? null; + const authenticated = response.status < 400 && code === 0; + return { + authenticated, + http_status: response.status, + code, + reason: authenticated ? "Token accepted by backend." : json.msg || "Backend rejected token.", + }; + } catch (error) { + return { + authenticated: false, + http_status: 0, + code: null, + reason: error.message, + }; + } + }, backendUrl); +} + +export function exitCode(status) { + if (status === "pass") return 0; + if (status === "blocked" || status === "env_issue") return 2; + return 1; +} + +export async function loadPlaywright() { + try { + return await import("playwright"); + } catch { + throw new Error( + "Playwright is not installed. Install it in this repo with `npm install --save-dev playwright`, then run `npx playwright install chromium`.", + ); + } +} + +export async function createBrowser(paths) { + const { chromium } = await loadPlaywright(); + const headed = env.LBS_HEADED === "1"; + const launchOptions = { + headless: !headed, + }; + if (env.LANGBOT_CHROMIUM_EXECUTABLE && await pathExists(env.LANGBOT_CHROMIUM_EXECUTABLE)) { + launchOptions.executablePath = env.LANGBOT_CHROMIUM_EXECUTABLE; + } + + let browser; + let context; + if (env.LANGBOT_BROWSER_PROFILE) { + context = await chromium.launchPersistentContext(resolve(env.LANGBOT_BROWSER_PROFILE), { + ...launchOptions, + viewport: { width: 1440, height: 960 }, + }); + } else { + browser = await chromium.launch(launchOptions); + context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + } + const page = context.pages()[0] || await context.newPage(); + + page.on("console", (message) => { + appendLine(paths.consoleLog, `[${message.type()}] ${message.text()}`).catch(() => {}); + }); + page.on("pageerror", (error) => { + appendLine(paths.consoleLog, `[pageerror] ${error.message}`).catch(() => {}); + }); + page.on("requestfailed", (request) => { + appendLine(paths.networkLog, `[requestfailed] ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`).catch(() => {}); + }); + page.on("response", (response) => { + if (response.status() < 400) return; + appendLine(paths.networkLog, `[response] ${response.status()} ${response.url()}`).catch(() => {}); + }); + + return { + page, + context, + async close() { + await context.close(); + if (browser) await browser.close(); + }, + }; +} + +export async function safeScreenshot(page, path) { + try { + await page.screenshot({ path, fullPage: true }); + } catch { + // Screenshot evidence is useful, but a screenshot failure should not hide the real test result. + } +} + +export async function gotoFrontend(page) { + const frontendUrl = env.LANGBOT_FRONTEND_URL; + if (!frontendUrl) { + throw new Error("LANGBOT_FRONTEND_URL is not configured."); + } + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); +} + +export function isLoginUrl(url) { + return /\/login(?:[/?#]|$)/.test(url); +} + +export async function bodyText(page) { + return await page.locator("body").innerText({ timeout: 5_000 }).catch(() => ""); +} + +export function countOccurrences(haystack, needle) { + if (!needle) return 0; + return String(haystack).split(needle).length - 1; +} + +export async function clickFirstVisible(page, labels, timeout = 2_000) { + for (const label of labels) { + const roleButton = page.getByRole("button", { name: label }).first(); + if (await roleButton.isVisible({ timeout }).catch(() => false)) { + await roleButton.click(); + return label; + } + + const roleLink = page.getByRole("link", { name: label }).first(); + if (await roleLink.isVisible({ timeout }).catch(() => false)) { + await roleLink.click(); + return label; + } + + const text = page.getByText(label, { exact: false }).first(); + if (await text.isVisible({ timeout }).catch(() => false)) { + await text.click(); + return label; + } + } + return null; +} + +export async function fillFirstTextInput(page, value) { + const candidates = [ + page.getByRole("textbox").last(), + page.locator("textarea").last(), + page.locator("[contenteditable=true]").last(), + page.locator("input[type=text]").last(), + ]; + + for (const locator of candidates) { + if (!await locator.isVisible({ timeout: 2_000 }).catch(() => false)) continue; + await locator.fill(value).catch(async () => { + await locator.click(); + await locator.pressSequentially(value); + }); + return true; + } + return false; +} + +export async function waitForVisibleText(page, text, timeout = 20_000) { + await page.getByText(text, { exact: false }).last().waitFor({ state: "visible", timeout }); +} diff --git a/skills/scripts/e2e/local-agent-steering-debug-chat.mjs b/skills/scripts/e2e/local-agent-steering-debug-chat.mjs new file mode 100755 index 0000000..dae2e26 --- /dev/null +++ b/skills/scripts/e2e/local-agent-steering-debug-chat.mjs @@ -0,0 +1,565 @@ +#!/usr/bin/env node + +import { writeFile } from "node:fs/promises"; +import { env } from "node:process"; +import { + DEBUG_CHAT_FAILURE_SIGNALS, + openPipelineDebugChat, + setDebugChatStreamOutput, + visibleDebugChatMessages, + waitForDebugChatTextStable, +} from "./lib/debug-chat.mjs"; +import { + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + localIsoWithOffset, + loadEnvFiles, + pathExists, + safeScreenshot, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +await loadEnvFiles(); + +const caseId = env.LBS_CASE_ID || "local-agent-steering-debug-chat"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const backendUrl = (env.LANGBOT_BACKEND_URL || "").replace(/\/$/, ""); +const pipelineUrl = env.LANGBOT_E2E_PIPELINE_URL || env.LANGBOT_LOCAL_AGENT_PIPELINE_URL || env.LANGBOT_PIPELINE_URL || ""; +const pipelineName = env.LANGBOT_E2E_PIPELINE_NAME || env.LANGBOT_LOCAL_AGENT_PIPELINE_NAME || env.LANGBOT_PIPELINE_NAME || ""; +const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || "plugin:langbot/local-agent/default"; +const expectedText = env.LANGBOT_E2E_EXPECTED_TEXT || "qa_steering_sentinel_6194"; +const responseTimeoutMs = positiveInt(env.LANGBOT_E2E_RESPONSE_TIMEOUT_MS, 240000); +const followupDelayMs = 1000; +const followupEnabledTimeoutMs = 1500; +const firstPrompt = env.LANGBOT_E2E_PROMPT || [ + "You are running the LangBot steering E2E test.", + "First call the qa_plugin_sleep tool with seconds=8 and text=steering-e2e-anchor.", + "Do not answer before the tool result is available.", + "After the tool returns, answer the latest user follow-up.", + "If no follow-up was injected, reply only STEERING_NO_FOLLOWUP.", +].join(" "); +const followupPrompt = [ + "This is a steering follow-up sent while the first tool call is still active.", + `Return only ${expectedText}.`, +].join(" "); + +const pipelineConfigDiagnosticPath = `${paths.evidenceDir}/pipeline-config-diagnostic.json`; +const debugChatResetDiagnosticPath = `${paths.evidenceDir}/debug-chat-reset-diagnostic.json`; +const toolDiagnosticPath = `${paths.evidenceDir}/tool-diagnostic.json`; + +let browser; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + started_at: new Date().toISOString(), + started_at_local: localIsoWithOffset(new Date()), + url: "", + backend_url: backendUrl, + pipeline_url: pipelineUrl, + pipeline_name: pipelineName, + expected_runner_id: expectedRunnerId, + first_prompt: firstPrompt, + followup_prompt: followupPrompt, + expected_text: expectedText, + followup_delay_ms: followupDelayMs, + followup_enabled_timeout_ms: followupEnabledTimeoutMs, + response_timeout_ms: responseTimeoutMs, + pipeline_config: null, + debug_chat_reset: null, + tool_diagnostic: null, + steering: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "console", "network", "screenshot"], +}; + +try { + if (!backendUrl) { + result.status = "env_issue"; + result.reason = "LANGBOT_BACKEND_URL is required."; + throw new Error(result.reason); + } + + browser = await createBrowser(paths); + const { page } = browser; + + const openResult = await openPipelineDebugChat(page, { + pipelineUrl, + pipelineName, + envHint: "case-specific pipeline env mapped to LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME", + }); + result.url = page.url(); + if (!openResult.opened) { + result.status = openResult.status; + result.reason = openResult.reason; + } else { + const pipelineDiagnostic = await inspectPipeline(page, { + backendUrl, + pipelineUrl, + pipelineName, + expectedRunnerId, + }); + await writeFile(pipelineConfigDiagnosticPath, `${JSON.stringify(pipelineDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.pipeline_config_diagnostic_json = pipelineConfigDiagnosticPath; + result.pipeline_config = pipelineDiagnostic; + if (!result.evidence_collected.includes("api_diagnostic")) result.evidence_collected.push("api_diagnostic"); + + const toolDiagnostic = await inspectToolNames(page, { backendUrl }); + await writeFile(toolDiagnosticPath, `${JSON.stringify(toolDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.tool_diagnostic_json = toolDiagnosticPath; + result.tool_diagnostic = toolDiagnostic; + + if (pipelineDiagnostic.status === "fail" || pipelineDiagnostic.status === "blocked") { + result.status = pipelineDiagnostic.status; + result.reason = pipelineDiagnostic.reason || "Pipeline diagnostic failed."; + } else if (toolDiagnostic.status === "fail" || toolDiagnostic.status === "blocked") { + result.status = toolDiagnostic.status; + result.reason = toolDiagnostic.reason || "Tool diagnostic failed."; + } else if (!toolDiagnostic.tool_names.includes("qa_plugin_sleep")) { + result.status = "blocked"; + result.reason = "qa_plugin_sleep is not exposed by /api/v1/tools; rebuild/reinstall qa-plugin-smoke before running steering E2E."; + } else { + const resetDiagnostic = await resetPipelineDebugChat(page, { + backendUrl, + pipelineId: pipelineDiagnostic.pipeline_id, + sessionType: "person", + }); + await writeFile(debugChatResetDiagnosticPath, `${JSON.stringify(resetDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.debug_chat_reset_diagnostic_json = debugChatResetDiagnosticPath; + result.debug_chat_reset = resetDiagnostic; + + if (resetDiagnostic.status === "fail" || resetDiagnostic.status === "blocked") { + result.status = resetDiagnostic.status; + result.reason = resetDiagnostic.reason || "Debug Chat reset failed."; + } else { + await page.waitForTimeout(1000); + const reopenResult = await openPipelineDebugChat(page, { + pipelineUrl, + pipelineName, + envHint: "case-specific pipeline env mapped to LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME", + }); + result.url = page.url(); + if (!reopenResult.opened) { + result.status = reopenResult.status; + result.reason = reopenResult.reason; + } else { + const streamResult = await setDebugChatStreamOutput(page, true); + if (streamResult.status === "blocked" || streamResult.status === "fail") { + result.status = streamResult.status; + result.reason = streamResult.reason; + } else { + result.steering = await runSteeringProbe(page); + result.status = result.steering.status; + result.reason = result.steering.reason; + } + } + } + } + } +} catch (error) { + if (!["env_issue", "blocked", "fail", "pass"].includes(result.status) || !result.reason) { + result.status = /Playwright is not installed|LANGBOT_FRONTEND_URL/.test(error.message) ? "env_issue" : "fail"; + } + result.reason = result.reason || error.message; +} finally { + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + const existingEvidence = {}; + for (const [key, value] of Object.entries(result.evidence)) { + if (typeof value !== "string") continue; + const isResultFile = value === paths.automationResultJson || value === paths.resultJson; + if (isResultFile || await pathExists(value)) existingEvidence[key] = value; + } + result.evidence = existingEvidence; + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); + +async function runSteeringProbe(page) { + const beforeMessages = await visibleDebugChatMessages(page); + const beforeAssistantCount = countRole(beforeMessages, "assistant"); + const beforeUserCount = countRole(beforeMessages, "user"); + const firstStartedAt = Date.now(); + const firstSend = await sendPrompt(page, firstPrompt, { enabledTimeoutMs: 5000 }); + if (!firstSend.sent) { + return { + status: "fail", + reason: firstSend.reason || "Could not send first Debug Chat prompt.", + first_send: firstSend, + before_assistant_count: beforeAssistantCount, + before_user_count: beforeUserCount, + }; + } + + await page.waitForTimeout(followupDelayMs); + const preFollowupMessages = await visibleDebugChatMessages(page); + const preFollowupAssistantCount = countRole(preFollowupMessages, "assistant"); + const followupStartedAt = Date.now(); + const followupSend = await sendPrompt(page, followupPrompt, { enabledTimeoutMs: followupEnabledTimeoutMs }); + const followupSentAt = Date.now(); + if (!followupSend.sent) { + return { + status: "fail", + reason: followupSend.reason || "Could not send steering follow-up while the first run was active.", + first_send: firstSend, + followup_send: followupSend, + first_to_followup_attempt_ms: followupStartedAt - firstStartedAt, + followup_send_latency_ms: followupSentAt - followupStartedAt, + before_assistant_count: beforeAssistantCount, + pre_followup_assistant_count: preFollowupAssistantCount, + before_user_count: beforeUserCount, + }; + } + + const waitResult = await waitForLatestAssistantContaining(page, { + expectedText, + beforeAssistantCount, + timeoutMs: responseTimeoutMs, + }); + await waitForDebugChatTextStable(page); + const afterMessages = await visibleDebugChatMessages(page); + const afterAssistantCount = countRole(afterMessages, "assistant"); + const afterUserCount = countRole(afterMessages, "user"); + const latestAssistantText = latestRoleText(afterMessages, "assistant"); + const failureSignal = findFailureSignal(latestAssistantText) || findFailureSignal(messagesText(afterMessages)); + const newAssistantCount = afterAssistantCount - beforeAssistantCount; + const newUserCount = afterUserCount - beforeUserCount; + + const base = { + first_send: firstSend, + followup_send: followupSend, + first_to_followup_attempt_ms: followupStartedAt - firstStartedAt, + followup_send_latency_ms: followupSentAt - followupStartedAt, + before_assistant_count: beforeAssistantCount, + pre_followup_assistant_count: preFollowupAssistantCount, + after_assistant_count: afterAssistantCount, + new_assistant_count: newAssistantCount, + before_user_count: beforeUserCount, + after_user_count: afterUserCount, + new_user_count: newUserCount, + latest_assistant_text: latestAssistantText, + assistant_containing_expected_seen: waitResult.seen, + failure_signal: failureSignal, + }; + + if (failureSignal) { + return { + ...base, + status: "fail", + reason: `Debug Chat displayed a known failure signal: ${failureSignal}`, + }; + } + if (!waitResult.seen) { + return { + ...base, + status: "fail", + reason: `No new assistant message contained steering sentinel ${expectedText}.`, + }; + } + if (!latestAssistantText.includes(expectedText)) { + return { + ...base, + status: "fail", + reason: `Latest assistant message did not contain steering sentinel ${expectedText}.`, + }; + } + if (newUserCount < 2) { + return { + ...base, + status: "fail", + reason: `Expected two new user messages, saw ${newUserCount}.`, + }; + } + if (newAssistantCount !== 1) { + return { + ...base, + status: "fail", + reason: `Expected one assistant response for one claimed steering run, saw ${newAssistantCount}. More than one usually means the follow-up became a separate run.`, + }; + } + if (latestAssistantText.includes("STEERING_NO_FOLLOWUP")) { + return { + ...base, + status: "fail", + reason: "Runner answered the no-follow-up branch, so steering was not injected.", + }; + } + + return { + ...base, + status: "pass", + reason: `Follow-up sentinel ${expectedText} appeared in the only new assistant response after two user messages.`, + }; +} + +function debugChatInput(page) { + return page + .locator('input[placeholder*="message"], input[placeholder*="消息"], textarea[placeholder*="message"], textarea[placeholder*="消息"]') + .last(); +} + +async function sendPrompt(page, prompt, { enabledTimeoutMs }) { + const input = debugChatInput(page); + const inputVisible = await input.isVisible({ timeout: 5000 }).catch(() => false); + if (!inputVisible) return { sent: false, reason: "Debug Chat input is not visible." }; + const inputEnabled = await input.isEnabled({ timeout: enabledTimeoutMs }).catch(() => false); + if (!inputEnabled) return { sent: false, reason: `Debug Chat input was not enabled within ${enabledTimeoutMs}ms.` }; + + await input.fill(prompt).catch(async () => { + await input.click(); + await input.pressSequentially(prompt); + }); + await input.press("Enter"); + await page.getByText(prompt, { exact: false }).last().waitFor({ state: "visible", timeout: 10000 }).catch(() => {}); + return { + sent: true, + submitted_by: "keyboard_enter", + }; +} + +async function waitForLatestAssistantContaining(page, { expectedText, beforeAssistantCount, timeoutMs }) { + const deadline = Date.now() + timeoutMs; + let lastMessages = []; + let latestAssistantText = ""; + while (Date.now() < deadline) { + const messages = await visibleDebugChatMessages(page); + lastMessages = messages; + latestAssistantText = latestRoleText(messages, "assistant"); + if (countRole(messages, "assistant") > beforeAssistantCount && latestAssistantText.includes(expectedText)) { + return { + seen: true, + latest_assistant_text: latestAssistantText, + messages, + }; + } + const failureSignal = findFailureSignal(latestAssistantText); + if (failureSignal) { + return { + seen: false, + latest_assistant_text: latestAssistantText, + messages, + failure_signal: failureSignal, + }; + } + await page.waitForTimeout(500); + } + return { + seen: false, + latest_assistant_text: latestAssistantText, + messages: lastMessages, + }; +} + +async function inspectPipeline(page, { backendUrl, pipelineUrl, pipelineName, expectedRunnerId }) { + const pipelineIdFromUrl = pipelineIdFromUrlValue(pipelineUrl); + return await page.evaluate(async ({ backendUrl, pipelineIdFromUrl, pipelineName, expectedRunnerId }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + reason: "Browser profile has no localStorage token.", + }; + } + const getJson = async (path) => { + const response = await fetch(`${backendUrl}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + + let pipelineId = pipelineIdFromUrl; + let matchedBy = pipelineId ? "url" : ""; + if (!pipelineId) { + if (!pipelineName) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: false, + reason: "Set LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME.", + }; + } + const list = await getJson("/api/v1/pipelines"); + const pipelines = list.json.data?.pipelines || []; + const match = pipelines.find((pipeline) => pipeline.name === pipelineName); + if (!match) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: false, + list_status: list.status, + reason: `Could not find pipeline named ${pipelineName}.`, + }; + } + pipelineId = match.uuid; + matchedBy = "name"; + } + + const loaded = await getJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`); + const pipeline = loaded.json.data?.pipeline; + if (loaded.status >= 400 || !pipeline) { + return { + status: "fail", + authenticated: true, + pipeline_resolved: false, + pipeline_id: pipelineId, + get_status: loaded.status, + reason: loaded.json.msg || "Could not load pipeline.", + }; + } + const config = pipeline.config || {}; + const runner = config.ai?.runner || {}; + const runnerId = runner.id || runner.runner || ""; + if (!runnerId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + reason: "Pipeline has no ai.runner.id or legacy ai.runner.runner.", + }; + } + if (expectedRunnerId && runnerId !== expectedRunnerId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + runner_id: runnerId, + expected_runner_id: expectedRunnerId, + reason: `Pipeline runner mismatch: expected ${expectedRunnerId}, got ${runnerId}.`, + }; + } + return { + status: "ready", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + runner_id: runnerId, + expected_runner_id: expectedRunnerId || "", + }; + }, { backendUrl, pipelineIdFromUrl, pipelineName, expectedRunnerId }); +} + +async function inspectToolNames(page, { backendUrl }) { + return await page.evaluate(async ({ backendUrl }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + tool_names: [], + reason: "Browser profile has no localStorage token.", + }; + } + const response = await fetch(`${backendUrl}/api/v1/tools`, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + const json = await response.json().catch(() => ({})); + const toolNames = (json.data?.tools || []) + .map((tool) => tool.name || tool.tool_name || tool.function?.name || "") + .filter(Boolean) + .sort(); + return { + status: response.status >= 400 ? "fail" : "ready", + authenticated: true, + http_status: response.status, + code: json.code ?? null, + tool_names: toolNames, + reason: response.status >= 400 ? json.msg || "Could not list tools." : "Tool list loaded.", + }; + }, { backendUrl }); +} + +async function resetPipelineDebugChat(page, { backendUrl, pipelineId, sessionType }) { + return await page.evaluate(async ({ backendUrl, pipelineId, sessionType }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + pipeline_id: pipelineId, + session_type: sessionType, + reason: "Browser profile has no localStorage token.", + }; + } + const response = await fetch( + `${backendUrl}/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/reset/${encodeURIComponent(sessionType)}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }, + ); + const json = await response.json().catch(() => ({})); + return { + status: response.status >= 400 ? "fail" : "ready", + authenticated: true, + pipeline_id: pipelineId, + session_type: sessionType, + reset_status: response.status, + reset_code: json.code ?? null, + reason: response.status >= 400 ? json.msg || "Debug Chat reset failed." : "Debug Chat session reset.", + }; + }, { backendUrl, pipelineId, sessionType }); +} + +function pipelineIdFromUrlValue(value) { + const match = String(value || "").match(/\/pipelines?\/([^/?#]+)/i); + return match ? decodeURIComponent(match[1]) : ""; +} + +function countRole(messages, role) { + return messages.filter((message) => message.role === role).length; +} + +function latestRoleText(messages, role) { + return messages.filter((message) => message.role === role).at(-1)?.text || ""; +} + +function messagesText(messages) { + return messages.map((message) => message.text).join("\n"); +} + +function findFailureSignal(text) { + return DEBUG_CHAT_FAILURE_SIGNALS.find((signal) => String(text || "").includes(signal)) || ""; +} + +function positiveInt(value, fallback) { + const parsed = Number.parseInt(String(value || ""), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} diff --git a/skills/scripts/e2e/mcp-stdio-fixture.mjs b/skills/scripts/e2e/mcp-stdio-fixture.mjs new file mode 100755 index 0000000..8941013 --- /dev/null +++ b/skills/scripts/e2e/mcp-stdio-fixture.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + ensureEvidence, + evidencePaths, + exitCode, + localIsoWithOffset, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +function loadEnvDefaults(path) { + if (!existsSync(path)) return; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf("="); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + if (env[key]) continue; + env[key] = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + } +} + +loadEnvDefaults("skills/.env"); +loadEnvDefaults("skills/.env.local"); + +const caseId = env.LBS_CASE_ID || "mcp-stdio-fixture-direct"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const fixturePath = resolve(env.LANGBOT_MCP_FIXTURE_PATH || "skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py"); +const langbotRepo = env.LANGBOT_REPO ? resolve(env.LANGBOT_REPO) : ""; +const uvCandidates = [ + env.LANGBOT_MCP_FIXTURE_UV, + "uv", +].filter(Boolean); +const uv = uvCandidates.find((candidate) => candidate === "uv" || existsSync(candidate)); +const pythonCandidates = [ + env.LANGBOT_MCP_FIXTURE_PYTHON, + langbotRepo ? `${langbotRepo}/.venv/bin/python` : "", + "python3", +].filter(Boolean); +const python = pythonCandidates.find((candidate) => candidate === "python3" || existsSync(candidate)); +const command = langbotRepo && uv + ? { executable: uv, args: ["run", "python", fixturePath], cwd: langbotRepo, mode: "uv" } + : python + ? { executable: python, args: [fixturePath], cwd: resolve("."), mode: "python" } + : null; +const expectedText = "qa_mcp_echo:mcp-stdio-fixture-ok"; + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + fixture_path: fixturePath, + command, + expected_text: expectedText, + evidence: { + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, +}; + +function parseJsonLines(buffer) { + return buffer + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean); +} + +async function request(child, id, method, params) { + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); +} + +async function run() { + if (!command) { + result.status = "env_issue"; + result.reason = "No uv or Python interpreter found. Set LANGBOT_REPO, LANGBOT_MCP_FIXTURE_UV, or LANGBOT_MCP_FIXTURE_PYTHON."; + return; + } + if (!existsSync(fixturePath)) { + result.status = "env_issue"; + result.reason = `MCP fixture not found: ${fixturePath}`; + return; + } + + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + + const timeout = setTimeout(() => child.kill("SIGTERM"), 10_000); + try { + await new Promise((resolveReady) => setTimeout(resolveReady, 100)); + await request(child, 1, "initialize", { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "langbot-skills", version: "0" }, + }); + await new Promise((resolveReady) => setTimeout(resolveReady, 200)); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`); + await request(child, 2, "tools/list", {}); + await request(child, 3, "tools/call", { + name: "qa_mcp_echo", + arguments: { text: "mcp-stdio-fixture-ok" }, + }); + await new Promise((resolveDone) => setTimeout(resolveDone, 1500)); + } finally { + clearTimeout(timeout); + child.kill("SIGTERM"); + } + + const messages = parseJsonLines(stdout); + if (/No module named ['"]mcp['"]|ModuleNotFoundError/i.test(stderr)) { + result.status = "env_issue"; + result.reason = `Python environment cannot import mcp. Set LANGBOT_MCP_FIXTURE_PYTHON to a LangBot venv Python. stderr=${stderr.trim()}`; + return; + } + const listResult = messages.find((message) => message.id === 2)?.result; + const callResult = messages.find((message) => message.id === 3)?.result; + const toolNames = Array.isArray(listResult?.tools) + ? listResult.tools.map((tool) => tool.name) + : []; + const callText = Array.isArray(callResult?.content) + ? callResult.content.map((item) => item.text || "").join("\n") + : ""; + + if (!toolNames.includes("qa_mcp_echo")) { + result.status = "fail"; + result.reason = `MCP fixture did not list qa_mcp_echo. stderr=${stderr.trim()}`; + return; + } + if (!callText.includes(expectedText)) { + result.status = "fail"; + result.reason = `MCP fixture call did not return ${expectedText}. stderr=${stderr.trim()}`; + return; + } + + result.status = "pass"; + result.reason = "MCP stdio fixture listed qa_mcp_echo and returned the deterministic tool result without a model provider."; +} + +try { + await run(); +} catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); +} finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/mcp-stdio-register.mjs b/skills/scripts/e2e/mcp-stdio-register.mjs new file mode 100755 index 0000000..e2e31a9 --- /dev/null +++ b/skills/scripts/e2e/mcp-stdio-register.mjs @@ -0,0 +1,234 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + localIsoWithOffset, + safeScreenshot, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +function loadEnvDefaults(path) { + if (!existsSync(path)) return; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf("="); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + if (env[key]) continue; + env[key] = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + } +} + +loadEnvDefaults("skills/.env"); +loadEnvDefaults("skills/.env.local"); + +const caseId = env.LBS_CASE_ID || "mcp-stdio-register"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const serverName = env.LANGBOT_MCP_SERVER_NAME || "qa-local-stdio"; +const expectedTool = env.LANGBOT_MCP_EXPECTED_TOOL || "qa_mcp_echo"; +const fixturePath = resolve(env.LANGBOT_MCP_FIXTURE_PATH || "skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py"); +const fixtureCommand = env.LANGBOT_MCP_FIXTURE_COMMAND || "python"; +const fixtureArgs = env.LANGBOT_MCP_FIXTURE_ARGS + ? JSON.parse(env.LANGBOT_MCP_FIXTURE_ARGS) + : [fixturePath]; +const startupTimeoutSec = Number(env.LANGBOT_MCP_STARTUP_TIMEOUT_SEC || "300"); +const readyTimeoutMs = Number(env.LANGBOT_MCP_READY_TIMEOUT_MS || "360000"); +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const apiDiagnosticPath = resolve(paths.evidenceDir, "api-diagnostic.json"); + +let browser; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + server_name: serverName, + fixture_path: fixturePath, + expected_tool: expectedTool, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + api_diagnostic_json: apiDiagnosticPath, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["api_diagnostic"], +}; + +async function run() { + if (!backendUrl) { + result.status = "env_issue"; + result.reason = "LANGBOT_BACKEND_URL is not configured."; + return; + } + if (!existsSync(fixturePath)) { + result.status = "env_issue"; + result.reason = `MCP fixture not found: ${fixturePath}`; + return; + } + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(env.LANGBOT_FRONTEND_URL, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + + const diagnostic = await page.evaluate(async ({ + backendUrl, + serverName, + expectedTool, + fixturePath, + fixtureCommand, + fixtureArgs, + startupTimeoutSec, + readyTimeoutMs, + }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + authenticated: false, + save_status: 0, + save_code: null, + save_msg: "Browser profile has no localStorage token.", + tool_names: [], + has_expected_tool: false, + runtime_status: null, + runtime_tool_names: [], + runtime_error: "", + }; + } + + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + const serverConfig = { + name: serverName, + mode: "stdio", + enable: true, + extra_args: { + command: fixtureCommand, + args: fixtureArgs, + env: {}, + box: { + startup_timeout_sec: startupTimeoutSec, + }, + }, + }; + const getJson = async (path) => { + const response = await fetch(`${backendUrl}${path}`, { headers }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + const sendJson = async (method, path, body) => { + const response = await fetch(`${backendUrl}${path}`, { + method, + headers, + body: JSON.stringify(body), + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + + const serverPath = `/api/v1/mcp/servers/${encodeURIComponent(serverName)}`; + const beforeServer = await getJson(serverPath); + const save = beforeServer.status === 404 + ? await sendJson("POST", "/api/v1/mcp/servers", serverConfig) + : await sendJson("PUT", serverPath, serverConfig); + + const deadline = Date.now() + readyTimeoutMs; + let lastTools = []; + let lastRuntime = null; + while (Date.now() < deadline) { + await new Promise((resolveReady) => setTimeout(resolveReady, 500)); + const tools = await getJson("/api/v1/tools"); + const server = await getJson(serverPath); + lastTools = (tools.json.data?.tools || []) + .map((tool) => tool.name || tool.tool_name || tool.function?.name || "") + .filter(Boolean) + .sort(); + lastRuntime = server.json.data?.server?.runtime_info || null; + if (lastTools.includes(expectedTool)) break; + } + + return { + authenticated: true, + before_status: beforeServer.status, + save_status: save.status, + save_code: save.json.code ?? null, + save_msg: save.json.msg || "", + tool_names: lastTools, + has_expected_tool: lastTools.includes(expectedTool), + runtime_status: lastRuntime?.status || null, + runtime_tool_names: (lastRuntime?.tools || []) + .map((tool) => tool.name || tool.tool_name || "") + .filter(Boolean) + .sort(), + runtime_tool_count: lastRuntime?.tool_count ?? null, + runtime_error: lastRuntime?.error_message || "", + }; + }, { backendUrl, serverName, expectedTool, fixturePath, fixtureCommand, fixtureArgs, startupTimeoutSec, readyTimeoutMs }); + + await writeFile(apiDiagnosticPath, `${JSON.stringify(diagnostic, null, 2)}\n`, "utf8"); + await safeScreenshot(page, paths.screenshot); + + if (!diagnostic.authenticated) { + result.status = "blocked"; + result.reason = "Browser profile is not authenticated for LangBot; cannot update MCP server."; + return; + } + if (diagnostic.save_status >= 400 || diagnostic.save_code !== 0) { + result.status = "fail"; + result.reason = `Failed to save MCP server ${serverName}: ${diagnostic.save_status} ${diagnostic.save_msg}`; + return; + } + if (diagnostic.runtime_status !== "connected") { + result.status = "fail"; + result.reason = `MCP server ${serverName} is not connected after save: ${diagnostic.runtime_status || "missing runtime"}. ${diagnostic.runtime_error}`; + return; + } + if (!diagnostic.has_expected_tool || !diagnostic.runtime_tool_names.includes(expectedTool)) { + result.status = "fail"; + result.reason = `MCP server ${serverName} did not expose ${expectedTool}. See ${apiDiagnosticPath}.`; + return; + } + + result.status = "pass"; + result.reason = `MCP server ${serverName} is connected and exposes ${expectedTool} through LangBot /api/v1/tools.`; +} + +try { + await run(); +} catch (error) { + result.status = /Playwright is not installed|LANGBOT_FRONTEND_URL/.test(error.message) ? "env_issue" : "fail"; + result.reason = error instanceof Error ? error.message : String(error); +} finally { + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/pipeline-debug-chat.mjs b/skills/scripts/e2e/pipeline-debug-chat.mjs new file mode 100755 index 0000000..4b20f77 --- /dev/null +++ b/skills/scripts/e2e/pipeline-debug-chat.mjs @@ -0,0 +1,806 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env } from "node:process"; +import { + openPipelineDebugChat, + runDebugChatPrompt, + setDebugChatStreamOutput, +} from "./lib/debug-chat.mjs"; +import { + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + localIsoWithOffset, + pathExists, + safeScreenshot, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = env.LBS_CASE_ID || "pipeline-debug-chat"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const expectedText = env.LANGBOT_E2E_EXPECTED_TEXT || "OK"; +const prompt = env.LANGBOT_E2E_PROMPT || `请只回复 ${expectedText},用于前端调试测试。`; +const responseTimeoutMs = Number.parseInt(env.LANGBOT_E2E_RESPONSE_TIMEOUT_MS || "120000", 10); +const safeResponseTimeoutMs = Number.isFinite(responseTimeoutMs) && responseTimeoutMs > 0 ? responseTimeoutMs : 120000; +const streamOutput = /^(0|false)$/i.test(env.LANGBOT_E2E_STREAM_OUTPUT || "") + ? false + : /^(1|true)$/i.test(env.LANGBOT_E2E_STREAM_OUTPUT || "") + ? true + : null; +const failureSignals = (env.LANGBOT_E2E_FAILURE_SIGNALS || "") + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); +const imageBase64Path = env.LANGBOT_E2E_IMAGE_BASE64_PATH || ""; +const imagePathEnv = env.LANGBOT_E2E_IMAGE_PATH || ""; +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const pipelineRequired = env.LANGBOT_E2E_PIPELINE_REQUIRED === "1"; +const pipelineUrl = pipelineRequired + ? env.LANGBOT_E2E_PIPELINE_URL + : (env.LANGBOT_E2E_PIPELINE_URL || env.LANGBOT_PIPELINE_URL); +const pipelineName = pipelineRequired + ? env.LANGBOT_E2E_PIPELINE_NAME + : (env.LANGBOT_E2E_PIPELINE_NAME || env.LANGBOT_PIPELINE_NAME); +const expectedRunnerId = env.LANGBOT_E2E_EXPECTED_RUNNER_ID || ""; +const resetDebugChat = boolFromEnv(env.LANGBOT_E2E_RESET_DEBUG_CHAT, false); +const restoreRunnerConfig = boolFromEnv(env.LANGBOT_E2E_RESTORE_RUNNER_CONFIG, true); +const debugChatSessionType = env.LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE || "person"; +const pipelineConfigDiagnosticPath = resolve(paths.evidenceDir, "pipeline-config-diagnostic.json"); +const debugChatResetDiagnosticPath = resolve(paths.evidenceDir, "debug-chat-reset-diagnostic.json"); +const pipelineConfigRestoreDiagnosticPath = resolve(paths.evidenceDir, "pipeline-config-restore-diagnostic.json"); +const metricsPath = resolve(paths.evidenceDir, "metrics.json"); +const startedAt = new Date(); + +let browser; +let restorePlan = null; +let result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + prompt, + expected_text: expectedText, + response_timeout_ms: safeResponseTimeoutMs, + stream_output: streamOutput, + image_fixture: imageBase64Path || imagePathEnv, + prompt_count: 1, + chat_results: [], + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + metrics_json: metricsPath, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "network", "metrics"], +}; + +function boolFromEnv(value, defaultValue) { + if (value === undefined || value === "") return defaultValue; + if (/^(0|false|no|off)$/i.test(value)) return false; + if (/^(1|true|yes|on)$/i.test(value)) return true; + return defaultValue; +} + +function parseJsonEnv(key, fallback) { + const raw = env[key]; + if (!raw) return fallback; + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`${key} must be valid JSON: ${error.message}`); + } +} + +function positiveNumberEnv(key, fallback) { + const value = Number(env[key] || ""); + return Number.isFinite(value) && value >= 0 ? value : fallback; +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return Number(sorted[index].toFixed(3)); +} + +function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: Number(Math.min(...values).toFixed(3)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: Number(Math.max(...values).toFixed(3)), + }; +} + +function promptStepsFromEnv() { + const rawSteps = parseJsonEnv("LANGBOT_E2E_PROMPTS_JSON", null); + if (rawSteps === null) { + return [{ prompt, expectedText, responseTimeoutMs: safeResponseTimeoutMs }]; + } + if (!Array.isArray(rawSteps) || rawSteps.length === 0) { + throw new Error("LANGBOT_E2E_PROMPTS_JSON must be a non-empty JSON array."); + } + return rawSteps.map((item, index) => { + if (typeof item === "string") { + return { prompt: item, expectedText, responseTimeoutMs: safeResponseTimeoutMs }; + } + if (!item || typeof item !== "object" || typeof item.prompt !== "string" || !item.prompt) { + throw new Error(`LANGBOT_E2E_PROMPTS_JSON[${index}] must be a string or an object with a prompt string.`); + } + const stepTimeout = Number.parseInt(String(item.response_timeout_ms || item.responseTimeoutMs || safeResponseTimeoutMs), 10); + return { + prompt: item.prompt, + expectedText: String(item.expected_text || item.expectedText || expectedText), + responseTimeoutMs: Number.isFinite(stepTimeout) && stepTimeout > 0 ? stepTimeout : safeResponseTimeoutMs, + }; + }); +} + +function expandEnvRefs(value) { + return String(value || "").replace(/\$\{([A-Z][A-Z0-9_]*)\}|\$([A-Z][A-Z0-9_]*)/g, (_match, braced, bare) => { + return env[braced || bare] || ""; + }); +} + +function textList(value) { + if (value === undefined || value === null || value === "") return []; + return Array.isArray(value) ? value.map(String) : [String(value)]; +} + +function runArgv(argv, { cwd = "", timeoutMs = 30_000 } = {}) { + return new Promise((resolveRun) => { + if (!Array.isArray(argv) || argv.length === 0 || !argv.every((item) => typeof item === "string" && item)) { + resolveRun({ + status: "fail", + reason: "Filesystem command check requires a non-empty argv string array.", + exit_code: null, + stdout: "", + stderr: "", + }); + return; + } + + const child = spawn(argv[0], argv.slice(1), { + cwd: cwd ? resolve(cwd) : undefined, + env, + shell: false, + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + clearTimeout(timer); + resolveRun({ + status: "fail", + reason: error.message, + exit_code: null, + stdout, + stderr, + }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolveRun({ + status: timedOut ? "fail" : "pass", + reason: timedOut ? `Command timed out after ${timeoutMs} ms.` : "", + exit_code: code, + stdout, + stderr, + }); + }); + }); +} + +async function runFilesystemChecks(checks) { + if (!Array.isArray(checks) || checks.length === 0) { + return { status: "not_required", checks: [] }; + } + const results = []; + for (let index = 0; index < checks.length; index += 1) { + const check = checks[index]; + if (!check || typeof check !== "object") { + results.push({ index, status: "fail", reason: "Filesystem check must be an object." }); + continue; + } + const contains = textList(check.contains); + const notContains = textList(check.not_contains || check.notContains); + const expectedExitCode = Number.isInteger(check.exit_code) + ? check.exit_code + : Number.isInteger(check.expected_exit_code) + ? check.expected_exit_code + : 0; + const expectedStdout = textList(check.stdout_contains || check.expected_stdout || check.expectedStdout); + + if (check.path) { + const path = resolve(expandEnvRefs(check.path)); + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch (error) { + results.push({ index, status: "fail", type: "file", path, reason: error.message }); + continue; + } + const missing = contains.filter((needle) => !text.includes(needle)); + const forbidden = notContains.filter((needle) => text.includes(needle)); + results.push({ + index, + status: missing.length || forbidden.length ? "fail" : "pass", + type: "file", + path, + missing, + forbidden, + reason: missing.length + ? `Missing expected text: ${missing.join(", ")}` + : forbidden.length + ? `Found forbidden text: ${forbidden.join(", ")}` + : "", + }); + continue; + } + + if (check.argv) { + const cwd = check.cwd ? expandEnvRefs(check.cwd) : ""; + const timeoutMs = Number.parseInt(String(check.timeout_ms || check.timeoutMs || "30000"), 10); + const run = await runArgv(check.argv.map(expandEnvRefs), { + cwd, + timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30_000, + }); + const missingStdout = expectedStdout.filter((needle) => !run.stdout.includes(needle)); + const exitMatches = run.exit_code === expectedExitCode; + results.push({ + index, + status: run.status === "pass" && exitMatches && missingStdout.length === 0 ? "pass" : "fail", + type: "command", + argv: check.argv, + cwd, + exit_code: run.exit_code, + expected_exit_code: expectedExitCode, + missing_stdout: missingStdout, + stdout_preview: run.stdout.slice(0, 2000), + stderr_preview: run.stderr.slice(0, 2000), + reason: run.reason + || (!exitMatches ? `Expected exit code ${expectedExitCode}, saw ${run.exit_code}.` : "") + || (missingStdout.length ? `Missing stdout text: ${missingStdout.join(", ")}` : ""), + }); + continue; + } + + results.push({ index, status: "fail", reason: "Filesystem check requires either path or argv." }); + } + const failed = results.filter((item) => item.status !== "pass"); + return { + status: failed.length ? "fail" : "pass", + checks: results, + reason: failed.length ? `Filesystem checks failed: ${failed.map((item) => item.index).join(", ")}` : "", + }; +} + +function pipelineIdFromUrl(url) { + if (!url) return ""; + try { + const parsed = new URL(url); + return parsed.searchParams.get("id") || ""; + } catch { + return ""; + } +} + +function sanitizePipelineDiagnostic(diagnostic) { + const { restore_config: _restoreConfig, ...safe } = diagnostic || {}; + return safe; +} + +async function prepareImageFixture(paths) { + if (imagePathEnv) return resolve(imagePathEnv); + if (!imageBase64Path) return ""; + const source = resolve(imageBase64Path); + const target = resolve(paths.evidenceDir, "image-fixture.png"); + const encoded = await readFile(source, "utf8"); + await writeFile(target, Buffer.from(encoded.replace(/\s+/g, ""), "base64")); + return target; +} + +async function inspectAndPatchPipelineConfig(page, { + backendUrl, + pipelineUrl, + pipelineName, + runnerConfigPatch, + expectedRunnerId, +}) { + const pipelineIdFromUrlValue = pipelineIdFromUrl(pipelineUrl) || pipelineIdFromUrl(page.url()); + return await page.evaluate(async ({ + backendUrl, + pipelineIdFromUrlValue, + pipelineName, + runnerConfigPatch, + expectedRunnerId, + }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + reason: "Browser profile has no localStorage token.", + }; + } + + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + const getJson = async (path) => { + const response = await fetch(`${backendUrl}${path}`, { headers }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + const putJson = async (path, body) => { + const response = await fetch(`${backendUrl}${path}`, { + method: "PUT", + headers, + body: JSON.stringify(body), + }); + return { + status: response.status, + json: await response.json().catch(() => ({})), + }; + }; + + let pipelineId = pipelineIdFromUrlValue || ""; + let matchedBy = pipelineId ? "url" : ""; + if (!pipelineId && pipelineName) { + const list = await getJson("/api/v1/pipelines"); + const pipelines = list.json.data?.pipelines || []; + const match = pipelines.find((pipeline) => pipeline.name === pipelineName); + if (!match) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: false, + list_status: list.status, + reason: `Could not find pipeline named ${pipelineName}.`, + }; + } + pipelineId = match.uuid; + matchedBy = "name"; + } + + if (!pipelineId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: false, + reason: "Could not resolve pipeline id from URL or pipeline name.", + }; + } + + const before = await getJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`); + const pipeline = before.json.data?.pipeline; + if (before.status >= 400 || !pipeline) { + return { + status: "fail", + authenticated: true, + pipeline_resolved: false, + pipeline_id: pipelineId, + get_status: before.status, + reason: before.json.msg || "Could not load pipeline.", + }; + } + + const config = JSON.parse(JSON.stringify(pipeline.config || {})); + const aiConfig = config.ai && typeof config.ai === "object" ? config.ai : {}; + const runner = aiConfig.runner && typeof aiConfig.runner === "object" ? aiConfig.runner : {}; + const runnerId = runner.id || runner.runner || ""; + if (!runnerId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + reason: "Pipeline has no ai.runner.id or legacy ai.runner.runner.", + }; + } + if (expectedRunnerId && runnerId !== expectedRunnerId) { + return { + status: "blocked", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + runner_id: runnerId, + expected_runner_id: expectedRunnerId, + reason: `Pipeline runner mismatch: expected ${expectedRunnerId}, got ${runnerId}.`, + }; + } + + const runnerConfigs = aiConfig.runner_config && typeof aiConfig.runner_config === "object" + ? aiConfig.runner_config + : {}; + const currentRunnerConfig = runnerConfigs[runnerId] && typeof runnerConfigs[runnerId] === "object" + ? runnerConfigs[runnerId] + : {}; + const patchKeys = Object.keys(runnerConfigPatch || {}); + const baseDiagnostic = { + status: "ready", + authenticated: true, + pipeline_resolved: true, + pipeline_id: pipelineId, + pipeline_name: pipeline.name, + matched_by: matchedBy, + runner_id: runnerId, + expected_runner_id: expectedRunnerId || "", + patch_keys: patchKeys, + runner_config_before_keys: Object.keys(currentRunnerConfig), + patched: patchKeys.length > 0, + }; + + if (patchKeys.length === 0) { + return baseDiagnostic; + } + + const updatedRunnerConfig = { + ...currentRunnerConfig, + ...runnerConfigPatch, + }; + const updatedConfig = { + ...config, + ai: { + ...aiConfig, + runner: { + ...runner, + id: runnerId, + }, + runner_config: { + ...runnerConfigs, + [runnerId]: updatedRunnerConfig, + }, + }, + }; + + const update = await putJson(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { + config: updatedConfig, + }); + if (update.status >= 400) { + return { + ...baseDiagnostic, + status: "fail", + put_status: update.status, + put_code: update.json.code ?? null, + reason: update.json.msg || "Pipeline config update failed.", + }; + } + + return { + ...baseDiagnostic, + put_status: update.status, + put_code: update.json.code ?? null, + runner_config_after_keys: Object.keys(updatedRunnerConfig), + restore_config: config, + }; + }, { + backendUrl, + pipelineIdFromUrlValue, + pipelineName, + runnerConfigPatch, + expectedRunnerId, + }); +} + +async function restorePipelineConfig(page, { backendUrl, pipelineId, config }) { + return await page.evaluate(async ({ backendUrl, pipelineId, config }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + pipeline_id: pipelineId, + reason: "Browser profile has no localStorage token.", + }; + } + const response = await fetch(`${backendUrl}/api/v1/pipelines/${encodeURIComponent(pipelineId)}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ config }), + }); + const json = await response.json().catch(() => ({})); + return { + status: response.status >= 400 ? "fail" : "ready", + authenticated: true, + pipeline_id: pipelineId, + put_status: response.status, + put_code: json.code ?? null, + reason: response.status >= 400 ? json.msg || "Pipeline config restore failed." : "Pipeline config restored.", + }; + }, { backendUrl, pipelineId, config }); +} + +async function resetPipelineDebugChat(page, { backendUrl, pipelineId, sessionType }) { + return await page.evaluate(async ({ backendUrl, pipelineId, sessionType }) => { + const token = localStorage.getItem("token"); + if (!token) { + return { + status: "blocked", + authenticated: false, + pipeline_id: pipelineId, + session_type: sessionType, + reason: "Browser profile has no localStorage token.", + }; + } + const response = await fetch( + `${backendUrl}/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/reset/${encodeURIComponent(sessionType)}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }, + ); + const json = await response.json().catch(() => ({})); + return { + status: response.status >= 400 ? "fail" : "ready", + authenticated: true, + pipeline_id: pipelineId, + session_type: sessionType, + reset_status: response.status, + reset_code: json.code ?? null, + reason: response.status >= 400 ? json.msg || "Debug Chat reset failed." : "Debug Chat session reset.", + }; + }, { backendUrl, pipelineId, sessionType }); +} + +try { + browser = await createBrowser(paths); + const { page } = browser; + const imagePath = await prepareImageFixture(paths); + const promptSteps = promptStepsFromEnv(); + const filesystemChecks = parseJsonEnv("LANGBOT_E2E_FILESYSTEM_CHECKS_JSON", []); + const runnerConfigPatch = parseJsonEnv("LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON", {}); + const runnerPatchKeys = Object.keys(runnerConfigPatch); + if (runnerPatchKeys.length > 0 || resetDebugChat || expectedRunnerId) { + if (!backendUrl) { + result.status = "env_issue"; + result.reason = "LANGBOT_BACKEND_URL is required for runner config patch, runner assertion, or Debug Chat reset."; + throw new Error(result.reason); + } + } + result.prompt_count = promptSteps.length; + result.prompt = promptSteps.length === 1 ? promptSteps[0].prompt : `${promptSteps.length} prompts`; + result.expected_text = promptSteps.at(-1)?.expectedText || expectedText; + + const openResult = await openPipelineDebugChat(page, { + pipelineUrl, + pipelineName, + envHint: pipelineRequired + ? "case-specific pipeline env mapped to LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME" + : "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME", + }); + result.url = page.url(); + + if (!openResult.opened) { + result.status = openResult.status; + result.reason = openResult.reason; + } else { + result.status = "running"; + result.reason = ""; + if (runnerPatchKeys.length > 0 || resetDebugChat || expectedRunnerId) { + const pipelineDiagnostic = await inspectAndPatchPipelineConfig(page, { + backendUrl, + pipelineUrl, + pipelineName, + runnerConfigPatch, + expectedRunnerId, + }); + const safeDiagnostic = sanitizePipelineDiagnostic(pipelineDiagnostic); + await writeFile(pipelineConfigDiagnosticPath, `${JSON.stringify(safeDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.pipeline_config_diagnostic_json = pipelineConfigDiagnosticPath; + result.pipeline_config = safeDiagnostic; + if (!result.evidence_collected.includes("api_diagnostic")) result.evidence_collected.push("api_diagnostic"); + + if (pipelineDiagnostic.status === "fail" || pipelineDiagnostic.status === "blocked") { + result.status = pipelineDiagnostic.status; + result.reason = pipelineDiagnostic.reason || "Pipeline config preparation failed."; + } else { + if (pipelineDiagnostic.restore_config && restoreRunnerConfig) { + restorePlan = { + backendUrl, + pipelineId: pipelineDiagnostic.pipeline_id, + config: pipelineDiagnostic.restore_config, + }; + } + if (resetDebugChat) { + const resetDiagnostic = await resetPipelineDebugChat(page, { + backendUrl, + pipelineId: pipelineDiagnostic.pipeline_id, + sessionType: debugChatSessionType, + }); + await writeFile(debugChatResetDiagnosticPath, `${JSON.stringify(resetDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.debug_chat_reset_diagnostic_json = debugChatResetDiagnosticPath; + result.debug_chat_reset = resetDiagnostic; + if (resetDiagnostic.status === "fail" || resetDiagnostic.status === "blocked") { + result.status = resetDiagnostic.status; + result.reason = resetDiagnostic.reason || "Debug Chat reset failed."; + } else { + await page.waitForTimeout(1000); + const reopenResult = await openPipelineDebugChat(page, { + pipelineUrl, + pipelineName, + envHint: pipelineRequired + ? "case-specific pipeline env mapped to LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME" + : "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME", + }); + result.url = page.url(); + if (!reopenResult.opened) { + result.status = reopenResult.status; + result.reason = reopenResult.reason; + } + } + } + } + } + + if (result.status === "fail" || result.status === "blocked" || result.status === "env_issue") { + // Preparation already determined the outcome. + } else { + const streamResult = await setDebugChatStreamOutput(page, streamOutput); + if (streamResult.status === "blocked" || streamResult.status === "fail") { + result.status = streamResult.status; + result.reason = streamResult.reason; + } else { + for (let index = 0; index < promptSteps.length; index += 1) { + const step = promptSteps[index]; + const promptStartedAt = Date.now(); + const chatResult = await runDebugChatPrompt(page, { + prompt: step.prompt, + expectedText: step.expectedText, + responseTimeoutMs: step.responseTimeoutMs, + imagePath: index === 0 ? imagePath : "", + failureSignals: failureSignals.length > 0 ? failureSignals : undefined, + }); + const promptDurationMs = Date.now() - promptStartedAt; + result.chat_results.push({ + index, + expected_text: step.expectedText, + status: chatResult.status, + reason: chatResult.reason, + response_duration_ms: promptDurationMs, + min_expected_count: chatResult.min_expected_count, + final_count: chatResult.final_count, + before_assistant_expected_count: chatResult.before_assistant_expected_count, + after_assistant_expected_count: chatResult.after_assistant_expected_count, + failure_signal: chatResult.failure_signal || "", + }); + result.status = chatResult.status; + result.reason = `Prompt ${index + 1}/${promptSteps.length}: ${chatResult.reason}`; + if (chatResult.status !== "pass") break; + } + } + } + + if (result.status === "pass" && filesystemChecks.length > 0) { + const filesystemResult = await runFilesystemChecks(filesystemChecks); + result.filesystem_checks = filesystemResult; + if (!result.evidence_collected.includes("filesystem")) result.evidence_collected.push("filesystem"); + if (filesystemResult.status === "fail") { + result.status = "fail"; + result.reason = filesystemResult.reason || "Filesystem checks failed."; + } + } + } +} catch (error) { + if (!["env_issue", "blocked", "fail", "pass"].includes(result.status) || !result.reason) { + result.status = /Playwright is not installed|LANGBOT_FRONTEND_URL/.test(error.message) ? "env_issue" : "fail"; + } + result.reason = result.reason || error.message; +} finally { + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); + if (browser?.page && restorePlan) { + const restoreDiagnostic = await restorePipelineConfig(browser.page, restorePlan).catch((error) => ({ + status: "fail", + pipeline_id: restorePlan.pipelineId, + reason: error.message, + })); + await writeFile(pipelineConfigRestoreDiagnosticPath, `${JSON.stringify(restoreDiagnostic, null, 2)}\n`, "utf8"); + result.evidence.pipeline_config_restore_diagnostic_json = pipelineConfigRestoreDiagnosticPath; + result.pipeline_config_restore = restoreDiagnostic; + } + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const responseDurations = result.chat_results + .map((item) => item.response_duration_ms) + .filter((value) => Number.isFinite(value)); + const passedPrompts = result.chat_results.filter((item) => item.status === "pass").length; + const attemptedPrompts = result.chat_results.length; + const errorRate = attemptedPrompts === 0 ? 1 : Number(((attemptedPrompts - passedPrompts) / attemptedPrompts).toFixed(4)); + const responseStats = stats(responseDurations); + const responseP95BudgetMs = positiveNumberEnv( + "LANGBOT_E2E_DEBUG_CHAT_RESPONSE_P95_MS", + positiveNumberEnv("LANGBOT_DEBUG_CHAT_RESPONSE_P95_MS", safeResponseTimeoutMs), + ); + const maxErrorRate = positiveNumberEnv("LANGBOT_E2E_DEBUG_CHAT_MAX_ERROR_RATE", 0); + const metrics = { + probe: caseId, + url: result.url, + prompt_count: result.prompt_count, + attempted_prompt_count: attemptedPrompts, + passed_prompt_count: passedPrompts, + error_rate: errorRate, + response_duration_ms: responseStats, + total_duration_ms: result.duration_ms, + chat_results: result.chat_results, + }; + result.metrics_summary = { + prompt_count: metrics.prompt_count, + attempted_prompt_count: metrics.attempted_prompt_count, + passed_prompt_count: metrics.passed_prompt_count, + error_rate: metrics.error_rate, + response_p50_ms: metrics.response_duration_ms.p50, + response_p95_ms: metrics.response_duration_ms.p95, + total_duration_ms: metrics.total_duration_ms, + }; + result.thresholds_summary = { + response_p95_ms: { + actual: metrics.response_duration_ms.p95, + max: responseP95BudgetMs, + pass: attemptedPrompts > 0 && metrics.response_duration_ms.p95 <= responseP95BudgetMs, + }, + error_rate: { + actual: metrics.error_rate, + max: maxErrorRate, + pass: metrics.error_rate <= maxErrorRate, + }, + }; + await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8"); + if (result.status === "pass" && !Object.values(result.thresholds_summary).every((item) => item.pass)) { + result.status = "fail"; + result.reason = "Debug Chat performance breached response latency or error-rate thresholds."; + } + const existingEvidence = {}; + for (const [key, value] of Object.entries(result.evidence)) { + if (typeof value !== "string") continue; + const isResultFile = value === paths.automationResultJson || value === paths.resultJson; + if (isResultFile || await pathExists(value)) existingEvidence[key] = value; + } + result.evidence = existingEvidence; + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/scripts/e2e/refresh-local-login.mjs b/skills/scripts/e2e/refresh-local-login.mjs new file mode 100755 index 0000000..66b4f34 --- /dev/null +++ b/skills/scripts/e2e/refresh-local-login.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { env } from "node:process"; +import { + bodyText, + createBrowser, + ensureEvidence, + evidencePaths, + loadEnvFiles, + resetAndAuthLocalUser, + safeScreenshot, + setBrowserToken, + verifyBrowserToken, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "refresh-local-login"; +const paths = evidencePaths(caseId); +await loadEnvFiles(); +await ensureEvidence(paths); + +const result = { + source: "automation", + case_id: caseId, + status: "fail", + reason: "", + user: env.LANGBOT_E2E_LOGIN_USER || "", + frontend_url: env.LANGBOT_FRONTEND_URL || "", + backend_url: env.LANGBOT_BACKEND_URL || "", + backend_token_check: null, + browser_token_check: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"], +}; + +let browser; + +try { + const backendUrl = env.LANGBOT_BACKEND_URL; + const frontendUrl = env.LANGBOT_FRONTEND_URL; + const user = env.LANGBOT_E2E_LOGIN_USER; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || "LangBotE2ELocalPass!2026"; + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!user) throw new Error("LANGBOT_E2E_LOGIN_USER is required."); + + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + result.backend_token_check = auth.check; + + browser = await createBrowser(paths); + const { page } = browser; + await setBrowserToken(page, frontendUrl, auth.token); + const browserCheck = await verifyBrowserToken(page, backendUrl); + result.browser_token_check = browserCheck; + if (!browserCheck.authenticated) { + throw new Error(browserCheck.reason || "Browser token check failed."); + } + + await page.goto(`${frontendUrl.replace(/\/$/, "")}/home/monitoring`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {}); + const text = await bodyText(page); + if (!text.includes("Dashboard") && !text.includes("Pipelines") && !text.includes("流水线")) { + throw new Error("Token was written, but authenticated navigation was not visible."); + } + + result.status = "pass"; + result.reason = "Browser profile localStorage token refreshed."; +} catch (error) { + result.status = "fail"; + result.reason = error.message; +} finally { + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); + if (browser) await browser.close().catch(() => {}); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(result.status === "pass" ? 0 : 1); diff --git a/skills/scripts/e2e/webui-login-state.mjs b/skills/scripts/e2e/webui-login-state.mjs new file mode 100755 index 0000000..fbbb53f --- /dev/null +++ b/skills/scripts/e2e/webui-login-state.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +import { + bodyText, + createBrowser, + ensureEvidence, + evidencePaths, + exitCode, + gotoFrontend, + isLoginUrl, + loadEnvFiles, + localIsoWithOffset, + safeScreenshot, + verifyBrowserToken, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "webui-login-state"; +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +let browser; +let result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + auth: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console"], +}; + +try { + browser = await createBrowser(paths); + const { page } = browser; + await gotoFrontend(page); + result.url = page.url(); + + const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; + if (!backendUrl) { + result.status = "env_issue"; + result.reason = "LANGBOT_BACKEND_URL is not configured."; + await safeScreenshot(page, paths.screenshot); + throw new Error(result.reason); + } + + const auth = await verifyBrowserToken(page, backendUrl); + result.auth = auth; + const text = await bodyText(page); + const navigationSignals = [ + "Dashboard", + "Bots", + "Pipelines", + "Knowledge", + "Plugins", + "首页", + "机器人", + "流水线", + "知识库", + "插件", + ]; + const matchedSignal = navigationSignals.find((signal) => text.includes(signal)); + + if (!auth.authenticated) { + result.status = "blocked"; + result.reason = auth.reason || "Browser profile token was not accepted by backend."; + } else if (isLoginUrl(page.url()) || /登录|Login|Sign in/i.test(text)) { + result.status = "fail"; + result.reason = "Backend accepted the token, but the WebUI still showed the login page."; + } else if (!matchedSignal) { + result.status = "fail"; + result.reason = "Opened WebUI, but no known LangBot navigation signal was visible."; + } else { + result.status = "pass"; + result.reason = `Authenticated navigation signal visible: ${matchedSignal}`; + } + + await safeScreenshot(page, paths.screenshot); +} catch (error) { + if (!["env_issue", "blocked", "fail", "pass"].includes(result.status) || !result.reason) { + result.status = /Playwright is not installed|LANGBOT_FRONTEND_URL/.test(error.message) ? "env_issue" : "fail"; + result.reason = error.message; + } +} finally { + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/skills.index.json b/skills/skills.index.json new file mode 100644 index 0000000..640996a --- /dev/null +++ b/skills/skills.index.json @@ -0,0 +1,1948 @@ +{ + "generated_by": "lbs", + "skills": [ + { + "directory": "langbot-deploy", + "name": "langbot-deploy", + "description": "Deploy and configure a LangBot instance — Docker / Docker Compose, Kubernetes, the config.yaml model, the Box sandbox runtime, the plugin runtime, and the global API key. Use when installing, deploying, upgrading, or configuring LangBot in production or self-hosted environments. Triggers on \"deploy langbot\", \"langbot docker\", \"langbot compose\", \"langbot kubernetes\", \"langbot config.yaml\", \"langbot box runtime\", \"langbot global api key\".", + "references": [], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-dev", + "name": "langbot-dev", + "description": "Develop, build, and debug the LangBot core backend and web frontend. Use when working inside the LangBot repository — backend (Python/Quart, src/langbot/pkg), the Vite/React web UI, HTTP API controllers/services, Alembic migrations, or the MCP server. Covers the dev environment (uv, pnpm), repo layout, the API auth model (user token / API key / global key), adding API endpoints, and the rule that API changes must update the MCP server and skills. Triggers on \"langbot backend\", \"langbot dev\", \"langbot api\", \"add langbot endpoint\", \"langbot migration\".", + "references": [], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-eba-adapter-dev", + "name": "langbot-eba-adapter-dev", + "description": "Build, refactor, and test LangBot platform adapters for the Event-Based Agents architecture. Use when adding or migrating Telegram, Discord, or other messaging platform adapters to the EBA adapter layout, validating unified event/message conversion, writing live adapter probes, or using standalone plugin runtime plus Computer Use for end-to-end platform testing.", + "references": [], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-env-setup", + "name": "langbot-env-setup", + "description": "Prepare a local LangBot development and testing environment for an AI agent. Use when setting up WSL or Linux development, shared local URL variables, proxy variables, backend/frontend startup, Playwright MCP browser access, GitHub OAuth browser login, persisted Chrome profiles, or future Codex computer-use environment paths.", + "references": [ + "references/browser-access-selection.md", + "references/computer-use.md", + "references/oauth-browser-profile.md", + "references/playwright-mcp.md", + "references/proxy.md", + "references/service-startup.md", + "references/wsl-notes.md" + ], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-mcp-ops", + "name": "langbot-mcp-ops", + "description": "Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on \"langbot mcp\", \"manage langbot via mcp\", \"langbot /mcp\", \"langbot mcp server\".", + "references": [], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-plugin-dev", + "name": "langbot-plugin-dev", + "description": "Develop, debug, and test LangBot plugins. Use when creating new LangBot plugins, fixing plugin bugs, setting up a LangBot test environment, or testing plugins via WebSocket. Covers plugin component architecture (EventListener, Command, Tool), the plugin SDK API (invoke_llm, get_llm_models, send_message, plugin storage), common pitfalls, and automated WebSocket-based testing. Triggers on \"langbot plugin\", \"lbp\", \"GroupChatSummary\", \"plugin debug\", \"langbot test\".", + "references": [ + "references/test-env-setup.md" + ], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-skills-maintenance", + "name": "langbot-skills-maintenance", + "description": "Maintain the langbot-skills repository with low duplication. Use when adding, editing, or auditing LangBot skills, references, cases, troubleshooting entries, indexes, or periodic entropy-control checks for this skills repository.", + "references": [ + "references/curation-workflow.md" + ], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-space-ops", + "name": "langbot-space-ops", + "description": "Browse and search the LangBot Space marketplaces (plugins, MCP servers, skills) through the Space MCP server. Use when an AI agent needs to discover LangBot extensions on space.langbot.app over MCP. Covers the /mcp endpoint, Personal Access Token (PAT) auth, the tool surface, and client configuration. Triggers on \"langbot space mcp\", \"search langbot plugins\", \"langbot marketplace mcp\", \"space.langbot.app mcp\".", + "references": [], + "cases": [], + "case_summaries": [], + "suites": [], + "suite_summaries": [], + "fixtures": [], + "troubleshooting": [], + "troubleshooting_summaries": [] + }, + { + "directory": "langbot-testing", + "name": "langbot-testing", + "description": "Test LangBot WebUI and core product flows with an automated browser and backend logs. Use when validating the configured LangBot frontend, pipeline Debug Chat, model provider setup and test buttons, bot and knowledge-base UI flows, or troubleshooting failed LangBot end-to-end tests.", + "references": [ + "references/agent-runner-qa-workflow.md", + "references/agent-runner-release-gate.md", + "references/dify-agent-runner.md", + "references/langrag-knowledge-base.md", + "references/local-agent-runner-coverage.md", + "references/local-agent-runner.md", + "references/mcp-stdio-testing.md", + "references/model-provider-testing.md", + "references/performance-reliability-testing.md", + "references/pipeline-debug-chat.md", + "references/plugin-e2e-smoke.md", + "references/sandbox-skill-authoring.md", + "references/troubleshooting.md", + "references/web-ui-testing.md" + ], + "cases": [ + "acp-agent-runner-debug-chat", + "agent-runner-async-db-readiness", + "agent-runner-behavior-matrix", + "agent-runner-fixture-contract", + "agent-runner-ledger-concurrency", + "agent-runner-ledger-contention", + "agent-runner-ledger-invariants", + "agent-runner-ledger-stress", + "agent-runner-live-install", + "agent-runner-qa-debug-chat", + "agent-runner-release-preflight", + "agent-runner-runtime-chaos", + "dify-agent-debug-chat", + "langbot-fake-provider-debug-chat-cross-pipeline-isolation", + "langbot-fake-provider-debug-chat-fault-recovery", + "langbot-fake-provider-debug-chat-load", + "langbot-fake-provider-debug-chat-slow-load", + "langbot-fault-taxonomy-contract", + "langbot-live-backend-latency", + "langbot-live-backend-log-health", + "langbot-live-control-plane-api", + "langbot-overhead-accounting-contract", + "langbot-space-debug-chat-concurrency-smoke", + "langrag-kb-retrieve", + "langrag-parser-golden-e2e", + "langrag-sentinel-kb-discover", + "local-agent-basic-debug-chat", + "local-agent-context-compaction-debug-chat", + "local-agent-effective-prompt-debug-chat", + "local-agent-multimodal-debug-chat", + "local-agent-nonstreaming-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "local-agent-rag-debug-chat", + "local-agent-rag-multimodal-debug-chat", + "local-agent-steering-debug-chat", + "mcp-stdio-register", + "mcp-stdio-tool-call", + "pipeline-debug-chat", + "pipeline-debug-chat-performance", + "plugin-e2e-smoke", + "provider-deepseek", + "qa-plugin-smoke-live-install", + "sandbox-skill-authoring-e2e", + "sandbox-skill-authoring-edit-existing-e2e", + "webui-login-state" + ], + "case_summaries": [ + { + "id": "acp-agent-runner-debug-chat", + "title": "ACP AgentRunner can answer through Debug Chat using real remote Claude", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p2", + "risk": "high", + "ci_eligible": false, + "tags": [ + "agent-runner", + "acp", + "claude", + "external-runner", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", + "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "console", + "backend_log" + ] + }, + { + "id": "agent-runner-async-db-readiness", + "title": "AgentRunner async DB readiness probe", + "mode": "probe", + "area": "release", + "type": "smoke", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "async-db", + "aiosqlite" + ], + "automation": "skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-behavior-matrix", + "title": "AgentRunner deterministic behavior matrix probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "deterministic-runner", + "protocol" + ], + "automation": "skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-fixture-contract", + "title": "QA AgentRunner fixture contract probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "fixture", + "deterministic-runner" + ], + "automation": "skills/langbot-testing/probes/agent-runner-fixture-contract.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-ledger-concurrency", + "title": "AgentRunner run ledger concurrency and auth pytest probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "pytest", + "run-ledger", + "concurrency" + ], + "automation": "skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-ledger-contention", + "title": "AgentRunner ledger SQLite contention probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "stress", + "ledger", + "concurrency" + ], + "automation": "skills/langbot-testing/probes/agent-runner-ledger-contention.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-ledger-invariants", + "title": "AgentRunner ledger schema and status invariants probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "ledger", + "invariant" + ], + "automation": "skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-ledger-stress", + "title": "AgentRunner ledger lightweight stress probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "stress", + "ledger" + ], + "automation": "skills/langbot-testing/probes/agent-runner-ledger-stress.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "agent-runner-live-install", + "title": "QA AgentRunner package installs and registers in LangBot", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": false, + "tags": [ + "agent-runner", + "plugin", + "local-install", + "fixture" + ], + "automation": "scripts/e2e/install-qa-plugin-smoke.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "agent-runner-qa-debug-chat", + "title": "QA AgentRunner returns deterministic output through Debug Chat", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": false, + "tags": [ + "agent-runner", + "pipeline", + "debug-chat", + "fixture" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "case:agent-runner-live-install", + "node:scripts/e2e/ensure-qa-agent-runner-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL", + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "network", + "api_diagnostic" + ] + }, + { + "id": "agent-runner-release-preflight", + "title": "Agent runner release gate preflight validates environment readiness", + "mode": "agent-browser", + "area": "release", + "type": "smoke", + "priority": "p0", + "risk": "high", + "ci_eligible": false, + "tags": [ + "agent-runner", + "release-gate", + "preflight", + "environment" + ], + "automation": "scripts/e2e/agent-runner-release-preflight.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "network", + "api_diagnostic" + ] + }, + { + "id": "agent-runner-runtime-chaos", + "title": "AgentRunner SDK runtime chaos pytest probe", + "mode": "probe", + "area": "release", + "type": "regression", + "priority": "p0", + "risk": "high", + "ci_eligible": true, + "tags": [ + "agent-runner", + "probe", + "pytest", + "runtime", + "sdk" + ], + "automation": "skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "filesystem" + ] + }, + { + "id": "dify-agent-debug-chat", + "title": "Dify AgentRunner returns a response through Pipeline Debug Chat", + "mode": "agent-browser", + "area": "pipeline", + "type": "provider", + "priority": "p2", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "dify", + "agent-runner", + "pipeline" + ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "console", + "backend_log" + ] + }, + { + "id": "langbot-fake-provider-debug-chat-cross-pipeline-isolation", + "title": "LangBot Debug Chat fake-provider cross-pipeline isolation probe", + "mode": "probe", + "area": "reliability", + "type": "reliability", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "reliability", + "debug-chat", + "websocket", + "fake-provider", + "isolation", + "concurrency", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-debug-chat-cross-pipeline-isolation.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-fake-provider-cross-pipelines.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_FAKE_PROVIDER_URL", + "LANGBOT_FAKE_PROVIDER_BASE_URL", + "LANGBOT_FAKE_PROVIDER_PID", + "LANGBOT_FAKE_PROVIDER_PIPELINE_A_URL", + "LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME", + "LANGBOT_FAKE_PROVIDER_PIPELINE_B_URL", + "LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME" + ], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-fake-provider-debug-chat-fault-recovery", + "title": "LangBot Debug Chat fake-provider fault recovery probe", + "mode": "probe", + "area": "reliability", + "type": "chaos", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "reliability", + "chaos", + "debug-chat", + "websocket", + "fake-provider", + "fault-injection", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_FAKE_PROVIDER_URL", + "LANGBOT_FAKE_PROVIDER_BASE_URL", + "LANGBOT_FAKE_PROVIDER_PID", + "LANGBOT_FAKE_PROVIDER_PROVIDER_UUID", + "LANGBOT_FAKE_PROVIDER_MODEL_UUID", + "LANGBOT_FAKE_PROVIDER_PIPELINE_URL", + "LANGBOT_FAKE_PROVIDER_PIPELINE_NAME" + ], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-fake-provider-debug-chat-load", + "title": "LangBot Debug Chat controlled fake-provider load probe", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "performance", + "debug-chat", + "websocket", + "fake-provider", + "load", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_FAKE_PROVIDER_URL", + "LANGBOT_FAKE_PROVIDER_BASE_URL", + "LANGBOT_FAKE_PROVIDER_PID", + "LANGBOT_FAKE_PROVIDER_PROVIDER_UUID", + "LANGBOT_FAKE_PROVIDER_MODEL_UUID", + "LANGBOT_FAKE_PROVIDER_PIPELINE_URL", + "LANGBOT_FAKE_PROVIDER_PIPELINE_NAME" + ], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-fake-provider-debug-chat-slow-load", + "title": "LangBot Debug Chat slow fake-provider load probe", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "performance", + "debug-chat", + "websocket", + "fake-provider", + "slow-provider", + "load", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_FAKE_PROVIDER_URL", + "LANGBOT_FAKE_PROVIDER_BASE_URL", + "LANGBOT_FAKE_PROVIDER_PID", + "LANGBOT_FAKE_PROVIDER_PROVIDER_UUID", + "LANGBOT_FAKE_PROVIDER_MODEL_UUID", + "LANGBOT_FAKE_PROVIDER_PIPELINE_URL", + "LANGBOT_FAKE_PROVIDER_PIPELINE_NAME" + ], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-fault-taxonomy-contract", + "title": "LangBot fault taxonomy and cleanup contract", + "mode": "probe", + "area": "reliability", + "type": "chaos", + "priority": "p1", + "risk": "medium", + "ci_eligible": true, + "tags": [ + "reliability", + "chaos", + "contract", + "synthetic" + ], + "automation": "skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "metrics", + "filesystem" + ] + }, + { + "id": "langbot-live-backend-latency", + "title": "LangBot live backend basic latency probe", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "performance", + "live-backend", + "latency", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-live-backend-latency.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-live-backend-log-health", + "title": "LangBot live backend log health probe", + "mode": "probe", + "area": "reliability", + "type": "reliability", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "reliability", + "live-backend", + "backend-log", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-live-backend-log-health.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "metrics", + "backend_log", + "filesystem" + ] + }, + { + "id": "langbot-live-control-plane-api", + "title": "LangBot live control-plane API probe", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "performance", + "reliability", + "live-backend", + "control-plane", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-live-control-plane-api.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langbot-overhead-accounting-contract", + "title": "LangBot overhead accounting metrics contract", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": true, + "tags": [ + "performance", + "metrics", + "contract", + "synthetic" + ], + "automation": "skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "metrics", + "resource_log", + "filesystem" + ] + }, + { + "id": "langbot-space-debug-chat-concurrency-smoke", + "title": "LangBot Debug Chat real Space-provider concurrency smoke", + "mode": "probe", + "area": "performance", + "type": "performance", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "performance", + "debug-chat", + "websocket", + "space", + "live-provider", + "smoke", + "metrics" + ], + "automation": "skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_PIPELINE_URL", + "LANGBOT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_MODEL_UUID", + "LANGBOT_E2E_MODEL_UUID" + ], + "evidence_required": [ + "metrics", + "network", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "langrag-kb-retrieve", + "title": "LangRAG knowledge base ingests and retrieves a sentinel document", + "mode": "agent-browser", + "area": "knowledge", + "type": "feature", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "langrag", + "knowledge", + "rag" + ], + "automation": "scripts/e2e/langrag-kb-retrieve.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "langrag-parser-golden-e2e", + "title": "LangRAG and GeneralParsers retrieve a structured golden document", + "mode": "agent-browser", + "area": "knowledge", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "golden", + "e2e", + "langrag", + "parser", + "knowledge" + ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "langrag-sentinel-kb-discover", + "title": "Existing LangRAG sentinel knowledge base is discoverable", + "mode": "probe", + "area": "knowledge", + "type": "regression", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "langrag", + "knowledge", + "rag", + "fixture" + ], + "automation": "scripts/e2e/ensure-langrag-sentinel-kb.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "api_diagnostic" + ] + }, + { + "id": "local-agent-basic-debug-chat", + "title": "Local Agent Debug Chat returns a deterministic streaming response", + "mode": "agent-browser", + "area": "pipeline", + "type": "smoke", + "priority": "p0", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "local-agent", + "pipeline", + "streaming" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "local-agent-context-compaction-debug-chat", + "title": "Local Agent compacts long Debug Chat history and preserves older facts", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "pipeline", + "context", + "compaction" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "local-agent-effective-prompt-debug-chat", + "title": "Local Agent consumes host effective prompt after PromptPreProcessing", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "prompt", + "plugin", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "console", + "backend_log" + ] + }, + { + "id": "local-agent-multimodal-debug-chat", + "title": "Local Agent Debug Chat preserves uploaded image input", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p2", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "local-agent", + "multimodal", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "network", + "backend_log" + ] + }, + { + "id": "local-agent-nonstreaming-debug-chat", + "title": "Local Agent Debug Chat returns a deterministic non-streaming response", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "local-agent", + "pipeline", + "non-streaming" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "console", + "backend_log" + ] + }, + { + "id": "local-agent-plugin-tool-call-debug-chat", + "title": "Local Agent can call a plugin-provided tool", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "plugin", + "tools", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "local-agent-rag-debug-chat", + "title": "Local Agent Debug Chat answers from a LangRAG knowledge base", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "langrag", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_RAG_KB_UUID" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "local-agent-rag-multimodal-debug-chat", + "title": "Local Agent preserves image input while using RAG context", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p2", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "rag", + "multimodal", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + "LANGBOT_LOCAL_AGENT_RAG_KB_UUID" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "local-agent-steering-debug-chat", + "title": "Local Agent injects a follow-up into an active run", + "mode": "agent-browser", + "area": "pipeline", + "type": "regression", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "local-agent", + "pipeline", + "steering", + "tools" + ], + "automation": "scripts/e2e/local-agent-steering-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "mcp-stdio-register", + "title": "MCP stdio fixture is registered and exposes qa_mcp_echo", + "mode": "agent-browser", + "area": "mcp", + "type": "smoke", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "mcp", + "tools", + "fixture", + "preflight" + ], + "automation": "scripts/e2e/mcp-stdio-register.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "screenshot", + "console", + "network", + "api_diagnostic" + ] + }, + { + "id": "mcp-stdio-tool-call", + "title": "MCP stdio server exposes a tool that Local Agent can call", + "mode": "agent-browser", + "area": "mcp", + "type": "feature", + "priority": "p1", + "risk": "high", + "ci_eligible": false, + "tags": [ + "mcp", + "tools", + "local-agent" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:mcp-stdio-register" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "pipeline-debug-chat", + "title": "Pipeline Debug Chat returns a bot response", + "mode": "agent-browser", + "area": "pipeline", + "type": "smoke", + "priority": "p0", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "smoke", + "pipeline" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "backend_log" + ] + }, + { + "id": "pipeline-debug-chat-performance", + "title": "Pipeline Debug Chat user-path performance probe", + "mode": "agent-browser", + "area": "pipeline", + "type": "performance", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "performance", + "pipeline", + "debug-chat", + "user-path", + "metrics" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_PIPELINE_URL", + "LANGBOT_PIPELINE_NAME" + ], + "evidence_required": [ + "ui", + "screenshot", + "console", + "network", + "metrics" + ] + }, + { + "id": "plugin-e2e-smoke", + "title": "Plugin system installs a local plugin and exposes tool/page APIs", + "mode": "agent-browser", + "area": "plugin", + "type": "smoke", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "plugin", + "runtime", + "local-install", + "e2e" + ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "console", + "backend_log", + "api_diagnostic" + ] + }, + { + "id": "provider-deepseek", + "title": "DeepSeek provider can be configured and used", + "mode": "agent-browser", + "area": "provider", + "type": "provider", + "priority": "p2", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "provider", + "model" + ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "console", + "backend_log" + ] + }, + { + "id": "qa-plugin-smoke-live-install", + "title": "QA plugin smoke package installs and exposes tools", + "mode": "probe", + "area": "plugin", + "type": "regression", + "priority": "p1", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "plugin", + "local-install", + "fixture" + ], + "automation": "scripts/e2e/install-qa-plugin-smoke.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "sandbox-skill-authoring-e2e", + "title": "Local Agent creates, registers, activates, and uses a sandbox skill", + "mode": "agent-browser", + "area": "sandbox", + "type": "regression", + "priority": "p2", + "risk": "high", + "ci_eligible": false, + "tags": [ + "sandbox", + "skills", + "local-agent", + "tools", + "e2b", + "nsjail" + ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "backend_log", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "sandbox-skill-authoring-edit-existing-e2e", + "title": "Local Agent modifies an activated sandbox skill package", + "mode": "agent-browser", + "area": "sandbox", + "type": "regression", + "priority": "p2", + "risk": "high", + "ci_eligible": false, + "tags": [ + "sandbox", + "skills", + "local-agent", + "tools", + "edit" + ], + "automation": "scripts/e2e/pipeline-debug-chat.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "backend_log", + "api_diagnostic", + "filesystem" + ] + }, + { + "id": "webui-login-state", + "title": "Configured frontend opens with authenticated LangBot WebUI state", + "mode": "agent-browser", + "area": "auth", + "type": "smoke", + "priority": "p0", + "risk": "low", + "ci_eligible": false, + "tags": [ + "smoke", + "auth" + ], + "automation": "scripts/e2e/webui-login-state.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console" + ] + } + ], + "suites": [ + "agent-runner-release-gate", + "core-smoke", + "langbot-debug-chat-isolation-gate", + "langbot-debug-chat-load-gate", + "langbot-live-backend-gate", + "langbot-performance-contract-gate", + "langbot-performance-reliability-gate", + "langbot-user-path-performance-gate", + "local-agent-gate" + ], + "suite_summaries": [ + { + "id": "agent-runner-release-gate", + "title": "Agent runner externalization release gate", + "description": "Release gate for runner externalization: pytest probes, environment preflight, fixture registration, local-agent capability paths, and ACP external harness execution.", + "type": "release_gate", + "priority": "p0", + "tags": [ + "agent-runner", + "release-gate", + "local-agent", + "external-runner" + ], + "cases": [ + "agent-runner-fixture-contract", + "agent-runner-behavior-matrix", + "agent-runner-ledger-invariants", + "agent-runner-async-db-readiness", + "agent-runner-ledger-stress", + "agent-runner-ledger-contention", + "agent-runner-ledger-concurrency", + "agent-runner-runtime-chaos", + "agent-runner-live-install", + "agent-runner-qa-debug-chat", + "agent-runner-release-preflight", + "webui-login-state", + "pipeline-debug-chat", + "qa-plugin-smoke-live-install", + "plugin-e2e-smoke", + "langrag-kb-retrieve", + "local-agent-basic-debug-chat", + "local-agent-effective-prompt-debug-chat", + "local-agent-context-compaction-debug-chat", + "local-agent-rag-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "mcp-stdio-register", + "mcp-stdio-tool-call", + "local-agent-nonstreaming-debug-chat", + "local-agent-multimodal-debug-chat", + "local-agent-rag-multimodal-debug-chat", + "acp-agent-runner-debug-chat" + ] + }, + { + "id": "core-smoke", + "title": "Core browser smoke suite", + "description": "Fast browser-first checks for login state, Pipeline Debug Chat, and the basic local-agent runner path.", + "type": "smoke", + "priority": "p0", + "tags": [ + "smoke", + "browser", + "p0" + ], + "cases": [ + "webui-login-state", + "pipeline-debug-chat", + "local-agent-basic-debug-chat" + ] + }, + { + "id": "langbot-debug-chat-isolation-gate", + "title": "LangBot Debug Chat isolation gate", + "description": "Manual/non-required cross-pipeline Debug Chat isolation gate. Current releases may fail this gate because of product bug #2286; use it as regression evidence after the routing fix lands.", + "type": "reliability", + "priority": "p1", + "tags": [ + "reliability", + "debug-chat", + "websocket", + "isolation", + "concurrency" + ], + "cases": [ + "langbot-fake-provider-debug-chat-cross-pipeline-isolation" + ] + }, + { + "id": "langbot-debug-chat-load-gate", + "title": "LangBot Debug Chat load gate", + "description": "Manual/non-required message-path load checks for Pipeline Debug Chat: controlled fake-provider baseline, slow-provider and fault-recovery profiles, plus optional real Space-provider smoke. Cross-pipeline isolation is split into langbot-debug-chat-isolation-gate because current releases may fail it due to product bug #2286.", + "type": "performance", + "priority": "p1", + "tags": [ + "performance", + "debug-chat", + "websocket", + "load" + ], + "cases": [ + "langbot-fake-provider-debug-chat-load", + "langbot-fake-provider-debug-chat-slow-load", + "langbot-fake-provider-debug-chat-fault-recovery", + "langbot-space-debug-chat-concurrency-smoke" + ] + }, + { + "id": "langbot-live-backend-gate", + "title": "LangBot live backend reliability gate", + "description": "Live backend control-plane responsiveness and runtime log health checks for a locally running LangBot instance.", + "type": "reliability", + "priority": "p1", + "tags": [ + "performance", + "reliability", + "live-backend", + "metrics" + ], + "cases": [ + "langbot-live-backend-latency", + "langbot-live-control-plane-api", + "langbot-live-backend-log-health" + ] + }, + { + "id": "langbot-performance-contract-gate", + "title": "LangBot performance contract gate", + "description": "Fast synthetic contract checks for performance metric accounting and non-destructive reliability fault taxonomy.", + "type": "contract", + "priority": "p1", + "tags": [ + "performance", + "reliability", + "contract", + "metrics" + ], + "cases": [ + "langbot-overhead-accounting-contract", + "langbot-fault-taxonomy-contract" + ] + }, + { + "id": "langbot-performance-reliability-gate", + "title": "LangBot performance and reliability starter gate", + "description": "Starter gate for LangBot performance accounting, live backend control-plane latency, and non-destructive fault taxonomy checks.", + "type": "reliability", + "priority": "p1", + "tags": [ + "performance", + "reliability", + "metrics", + "chaos" + ], + "cases": [ + "langbot-overhead-accounting-contract", + "langbot-fault-taxonomy-contract", + "langbot-live-backend-latency", + "langbot-live-control-plane-api", + "langbot-live-backend-log-health" + ] + }, + { + "id": "langbot-user-path-performance-gate", + "title": "LangBot user-path performance gate", + "description": "Browser-visible performance checks for user-facing LangBot paths such as Pipeline Debug Chat.", + "type": "performance", + "priority": "p1", + "tags": [ + "performance", + "browser", + "debug-chat", + "user-path" + ], + "cases": [ + "pipeline-debug-chat-performance" + ] + }, + { + "id": "local-agent-gate", + "title": "Local Agent runner regression gate", + "description": "High-signal local-agent runner checks covering prompt bridge, RAG, context compaction, plugin tools, MCP tools, non-streaming, and multimodal paths.", + "type": "regression", + "priority": "p1", + "tags": [ + "local-agent", + "agent-runner", + "regression" + ], + "cases": [ + "local-agent-basic-debug-chat", + "qa-plugin-smoke-live-install", + "local-agent-effective-prompt-debug-chat", + "local-agent-context-compaction-debug-chat", + "local-agent-rag-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "local-agent-steering-debug-chat", + "mcp-stdio-tool-call", + "local-agent-nonstreaming-debug-chat", + "local-agent-multimodal-debug-chat", + "local-agent-rag-multimodal-debug-chat" + ] + } + ], + "fixtures": [ + { + "id": "qa-agent-runner-behaviors", + "title": "Deterministic AgentRunner behavior matrix", + "kind": "json", + "path": "fixtures/agent-runner/qa-runner-behaviors.json", + "related_cases": [ + "agent-runner-behavior-matrix", + "agent-runner-ledger-invariants", + "agent-runner-runtime-chaos" + ] + }, + { + "id": "qa-agent-runner-source", + "title": "QA deterministic AgentRunner fixture source", + "kind": "plugin_source", + "path": "fixtures/plugins/qa-agent-runner/manifest.yaml", + "related_cases": [ + "agent-runner-fixture-contract", + "agent-runner-behavior-matrix", + "agent-runner-live-install", + "agent-runner-qa-debug-chat" + ] + }, + { + "id": "qa-agent-runner-package", + "title": "QA deterministic AgentRunner prebuilt package", + "kind": "plugin_package", + "path": "fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg", + "related_cases": [ + "agent-runner-fixture-contract", + "agent-runner-live-install", + "agent-runner-qa-debug-chat" + ] + }, + { + "id": "mcp-stdio-echo-server", + "title": "MCP stdio qa_mcp_echo server", + "kind": "python", + "path": "fixtures/mcp/qa_mcp_echo_server.py", + "related_cases": [ + "mcp-stdio-tool-call" + ] + }, + { + "id": "rag-sentinel-doc", + "title": "LangRAG sentinel text document", + "kind": "text", + "path": "fixtures/rag/sentinel-doc.txt", + "related_cases": [ + "langrag-kb-retrieve", + "local-agent-rag-debug-chat" + ] + }, + { + "id": "rag-parser-golden-html", + "title": "LangRAG parser golden HTML document", + "kind": "html", + "path": "fixtures/rag/parser-golden.html", + "related_cases": [ + "langrag-parser-golden-e2e" + ] + }, + { + "id": "multimodal-red-square", + "title": "64x64 red-square image fixture", + "kind": "base64_png", + "path": "fixtures/multimodal/red-square.png.base64", + "related_cases": [ + "local-agent-multimodal-debug-chat", + "local-agent-rag-multimodal-debug-chat" + ] + }, + { + "id": "qa-plugin-smoke-source", + "title": "QA plugin smoke fixture source", + "kind": "plugin_source", + "path": "fixtures/plugins/qa-plugin-smoke/manifest.yaml", + "related_cases": [ + "qa-plugin-smoke-live-install", + "plugin-e2e-smoke", + "local-agent-effective-prompt-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "local-agent-steering-debug-chat" + ] + }, + { + "id": "qa-plugin-smoke-package", + "title": "QA plugin smoke prebuilt package", + "kind": "plugin_package", + "path": "fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg", + "related_cases": [ + "qa-plugin-smoke-live-install", + "plugin-e2e-smoke" + ] + } + ], + "troubleshooting": [ + "agent-runner-actor-context-fields", + "aiosqlite-connect-hangs", + "ambiguous-runner-default-label", + "backend-not-listening", + "box-session-conflict-logical-metadata", + "debug-chat-history-contaminates-automation", + "dynamic-form-missing-config-id", + "e2b-extra-mount-sync-missing", + "local-agent-model-route-unavailable", + "marketplace-network-flaky", + "mcp-stdio-args-not-applied", + "nsjail-cli-compatibility", + "pipeline-form-controlled-warning", + "plugin-dependency-install-offline", + "plugin-runtime-timeout", + "provider-image-parse-error", + "proxy-env-mismatch", + "sandbox-native-tools-unavailable", + "socks-proxy-without-socksio", + "survey-widget-blocks-debug-chat", + "telemetry-proxy-noise", + "tool-name-collision-between-mcp-and-plugin", + "uv-run-resyncs-local-sdk" + ], + "troubleshooting_summaries": [ + { + "id": "agent-runner-actor-context-fields", + "title": "AgentRunner reads old actor.type and actor.id fields", + "category": "product", + "related_cases": [ + "dify-agent-debug-chat", + "pipeline-debug-chat" + ] + }, + { + "id": "aiosqlite-connect-hangs", + "title": "aiosqlite connect hangs before ledger pytest starts", + "category": "env_issue", + "related_cases": [ + "agent-runner-ledger-concurrency", + "agent-runner-ledger-invariants" + ] + }, + { + "id": "ambiguous-runner-default-label", + "title": "AgentRunner selector shows multiple Default or 默认 options", + "category": "product", + "related_cases": [ + "dify-agent-debug-chat", + "local-agent-rag-debug-chat" + ] + }, + { + "id": "backend-not-listening", + "title": "LangBot backend URL has no listening service", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "webui-login-state", + "local-agent-basic-debug-chat" + ] + }, + { + "id": "box-session-conflict-logical-metadata", + "title": "BoxSessionConflictError after a successful first exec", + "category": "product", + "related_cases": [ + "sandbox-skill-authoring-e2e" + ] + }, + { + "id": "debug-chat-history-contaminates-automation", + "title": "Old Debug Chat messages contaminate automation assertions", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "mcp-stdio-tool-call" + ] + }, + { + "id": "dynamic-form-missing-config-id", + "title": "Dynamic form fields have no stable key", + "category": "product", + "related_cases": [ + "langrag-kb-retrieve", + "local-agent-rag-debug-chat" + ] + }, + { + "id": "e2b-extra-mount-sync-missing", + "title": "Activated skill files are missing or not written back on E2B", + "category": "product", + "related_cases": [ + "sandbox-skill-authoring-e2e" + ] + }, + { + "id": "local-agent-model-route-unavailable", + "title": "Local Agent model route is unavailable for the requested run shape", + "category": "env_issue", + "related_cases": [ + "local-agent-basic-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "mcp-stdio-tool-call", + "local-agent-multimodal-debug-chat", + "local-agent-nonstreaming-debug-chat" + ] + }, + { + "id": "marketplace-network-flaky", + "title": "Marketplace requests are flaky but plugin data may still load", + "category": "product", + "related_cases": [ + "langrag-kb-retrieve" + ] + }, + { + "id": "mcp-stdio-args-not-applied", + "title": "MCP Stdio test runs only the command without arguments", + "category": "product", + "related_cases": [ + "mcp-stdio-tool-call" + ] + }, + { + "id": "nsjail-cli-compatibility", + "title": "Installed nsjail CLI rejects generated sandbox arguments", + "category": "product", + "related_cases": [ + "sandbox-skill-authoring-e2e" + ] + }, + { + "id": "pipeline-form-controlled-warning", + "title": "Pipeline form input switches from uncontrolled to controlled", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "local-agent-rag-debug-chat" + ] + }, + { + "id": "plugin-dependency-install-offline", + "title": "Local plugin dependency install fails in an offline or proxy-limited runtime", + "category": "product", + "related_cases": [ + "langrag-parser-golden-e2e" + ] + }, + { + "id": "plugin-runtime-timeout", + "title": "Plugin runtime actions time out", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "provider-deepseek", + "langrag-parser-golden-e2e" + ] + }, + { + "id": "provider-image-parse-error", + "title": "Model provider rejects the uploaded image before runner behavior is exercised", + "category": "product", + "related_cases": [ + "local-agent-multimodal-debug-chat" + ] + }, + { + "id": "proxy-env-mismatch", + "title": "Uppercase and lowercase proxy variables differ", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "provider-deepseek", + "webui-login-state" + ] + }, + { + "id": "sandbox-native-tools-unavailable", + "title": "Native sandbox tools are unavailable even though a backend is configured", + "category": "product", + "related_cases": [ + "sandbox-skill-authoring-e2e" + ] + }, + { + "id": "socks-proxy-without-socksio", + "title": "Python HTTP clients fail when ALL_PROXY uses SOCKS without socksio", + "category": "product", + "related_cases": [ + "sandbox-skill-authoring-e2e", + "provider-deepseek" + ] + }, + { + "id": "survey-widget-blocks-debug-chat", + "title": "Survey widget blocks Debug Chat controls", + "category": "product", + "related_cases": [ + "pipeline-debug-chat", + "local-agent-rag-debug-chat", + "mcp-stdio-tool-call" + ] + }, + { + "id": "telemetry-proxy-noise", + "title": "Telemetry posting fails through the proxy while the target flow succeeds", + "category": "env_issue", + "related_cases": [ + "langbot-space-debug-chat-concurrency-smoke" + ] + }, + { + "id": "tool-name-collision-between-mcp-and-plugin", + "title": "MCP and plugin expose the same tool name", + "category": "product", + "related_cases": [ + "mcp-stdio-tool-call", + "local-agent-plugin-tool-call-debug-chat" + ] + }, + { + "id": "uv-run-resyncs-local-sdk", + "title": "uv run resyncs the locked SDK instead of the local editable SDK", + "category": "product", + "related_cases": [ + "plugin-e2e-smoke" + ] + } + ] + } + ] +} diff --git a/skills/skills/.env b/skills/skills/.env new file mode 100644 index 0000000..4e5aae6 --- /dev/null +++ b/skills/skills/.env @@ -0,0 +1,46 @@ +# Shared defaults for LangBot skills. +# Agents should read this file first, then load machine-local overrides from +# skills/.env.local. Do not put workstation-specific absolute paths or secrets +# in this committed file. + +# The UI URL that testing skills should open. +# Default to the standalone Vite frontend. Set this to the backend WebUI URL +# instead if your LangBot checkout serves the frontend from the backend. +LANGBOT_FRONTEND_URL=http://127.0.0.1:3000 + +# LangBot API/backend URL. +LANGBOT_BACKEND_URL=http://127.0.0.1:5300 + +# Common standalone frontend dev URL. This is a candidate, not the default. +LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:3000 + +# Local repository paths. Copy skills/.env.example to skills/.env.local and set +# these for your checkout. +LANGBOT_REPO= +LANGBOT_WEB_REPO= +LANGBOT_RAG_PLUGIN_REPO= +LANGBOT_PARSER_PLUGIN_REPO= + +# Browser profile and Playwright/Chromium paths. +LANGBOT_BROWSER_PROFILE= +LANGBOT_CHROMIUM_EXECUTABLE= + +# Optional local proxy defaults. Do not store secrets here. +LANGBOT_PROXY_HTTP= +LANGBOT_PROXY_SOCKS= +LANGBOT_NO_PROXY=localhost,127.0.0.1,::1 + +# Optional case-specific pipeline targets. Put machine-local values in +# skills/.env.local so runner-specific cases do not accidentally reuse the +# generic LANGBOT_PIPELINE_URL. +# LANGBOT_PIPELINE_URL=http://127.0.0.1:3000/home/pipelines?id= +# LANGBOT_PIPELINE_NAME=Generic QA Pipeline +# LANGBOT_LOCAL_AGENT_PIPELINE_URL=http://127.0.0.1:3000/home/pipelines?id= +# LANGBOT_LOCAL_AGENT_PIPELINE_NAME=Local Agent QA Pipeline +# LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL=http://127.0.0.1:3000/home/pipelines?id= +# LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME=ACP AgentRunner QA Pipeline +# LANGBOT_ACP_AGENT_RUNNER_SSH_TARGET=yhh@101.34.71.12 +# LANGBOT_ACP_AGENT_RUNNER_SSH_PORT=22 +# LANGBOT_ACP_AGENT_RUNNER_SSH_IDENTITY_FILE= +# LANGBOT_ACP_AGENT_RUNNER_SSH_EXTRA_OPTIONS= +# LANGBOT_ACP_AGENT_RUNNER_REMOTE_WORKSPACE=/home/yhh/langbot-e2e/acp-workspace diff --git a/skills/skills/.env.example b/skills/skills/.env.example new file mode 100644 index 0000000..888c572 --- /dev/null +++ b/skills/skills/.env.example @@ -0,0 +1,53 @@ +# Copy this file to skills/.env.local and adjust it for your machine. +# Do not put API keys, OAuth tokens, browser localStorage tokens, or provider +# credentials in committed files. + +# LangBot WebUI and backend endpoints. +LANGBOT_FRONTEND_URL=http://127.0.0.1:3000 +LANGBOT_BACKEND_URL=http://127.0.0.1:5300 +LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:3000 + +# Local repository paths. +LANGBOT_REPO=/path/to/LangBot +LANGBOT_WEB_REPO=/path/to/LangBot/web +LANGBOT_RAG_PLUGIN_REPO=/path/to/langbot-rag +LANGBOT_PARSER_PLUGIN_REPO=/path/to/langbot-parser + +# Browser profile and Playwright/Chromium paths. +LANGBOT_BROWSER_PROFILE=/path/to/langbot-playwright-profile +LANGBOT_CHROMIUM_EXECUTABLE=/path/to/ms-playwright/chromium/chrome + +# Optional local proxy defaults. Leave blank if not needed. +LANGBOT_PROXY_HTTP= +LANGBOT_PROXY_SOCKS= +LANGBOT_NO_PROXY=localhost,127.0.0.1,::1 + +# Optional generic pipeline target for generic Debug Chat smoke tests. +LANGBOT_PIPELINE_URL= +LANGBOT_PIPELINE_NAME= + +# Optional fake OpenAI-compatible provider controls for Debug Chat load tests. +# Leave URL empty to let setup automation start a local provider and write the +# selected URL to skills/.env.local. +LANGBOT_FAKE_PROVIDER_URL= +LANGBOT_FAKE_PROVIDER_HOST=127.0.0.1 +LANGBOT_FAKE_PROVIDER_PORT= +LANGBOT_FAKE_PROVIDER_MODEL_NAME=gpt-4o-mini +LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT=OK +LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS=25 +LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS=10 +LANGBOT_FAKE_PROVIDER_CHUNK_COUNT=0 +LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N=0 +LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N=0 +LANGBOT_FAKE_PROVIDER_FAULT_STATUS=500 +LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK=false +LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE=true + +# Optional case-specific runner targets. Prefer these for runner-specific cases +# so the automation cannot silently test the wrong runner. +LANGBOT_LOCAL_AGENT_PIPELINE_URL= +LANGBOT_LOCAL_AGENT_PIPELINE_NAME= +LANGBOT_CODEX_AGENT_PIPELINE_URL= +LANGBOT_CODEX_AGENT_PIPELINE_NAME= +LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_URL= +LANGBOT_CLAUDE_CODE_AGENT_PIPELINE_NAME= diff --git a/skills/skills/langbot-deploy/SKILL.md b/skills/skills/langbot-deploy/SKILL.md new file mode 100644 index 0000000..3a314fb --- /dev/null +++ b/skills/skills/langbot-deploy/SKILL.md @@ -0,0 +1,84 @@ +--- +name: langbot-deploy +description: Deploy and configure a LangBot instance — Docker / Docker Compose, Kubernetes, the config.yaml model, the Box sandbox runtime, the plugin runtime, and the global API key. Use when installing, deploying, upgrading, or configuring LangBot in production or self-hosted environments. Triggers on "deploy langbot", "langbot docker", "langbot compose", "langbot kubernetes", "langbot config.yaml", "langbot box runtime", "langbot global api key". +--- + +# LangBot Deployment & Configuration + +Covers running LangBot in production. For development see `langbot-dev`. + +## Docker Compose (recommended) + +```bash +git clone https://github.com/langbot-app/LangBot +cd LangBot/docker + +# Full stack (sandbox/Box + stdio MCP hosting + skill add/edit enabled) +docker compose --profile all up + +# Basic (no Box runtime) +docker compose up +``` + +The `all` / `box` profile starts three services: + +- `langbot` — main app, serves API + UI on `:5300`. +- `langbot_plugin_runtime` — plugin runtime (control `:5400`, debug `:5401`). +- `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to + spawn sandbox containers, so the **Box root host path and in-container path + must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`). + +With Box off, the dashboard/skills list stays visible (read-only) but sandbox +tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false` +(or `BOX__ENABLED=false`) to match. + +## Kubernetes + +See `docker/kubernetes.yaml` and the deployment guide at +https://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper. + +## config.yaml (generated at `data/config.yaml` on first run) + +Top-level sections: `api`, `system`, `command`, `concurrency`, `proxy`, +`database`, `vdb`, `storage`, `plugin`, `monitoring`, `box`, `space`. + +Key settings: + +| Key | Meaning | +| --- | --- | +| `api.port` | HTTP API + UI port (default 5300) | +| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. | +| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) | +| `box.enabled` | Master switch for the Box sandbox runtime | +| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` | +| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed | + +Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`). + +## Runtimes & flags + +- LangBot started directly spawns the plugin runtime over **stdio**. +- In containers it connects to a standalone runtime over **WebSocket**; start + with `--standalone-runtime`. +- Box has a parallel `--standalone-box` flag; the Docker box host is + `langbot_box:5410`. + +## Global API key — enabling for agents/automation + +```yaml +# data/config.yaml +api: + port: 5300 + global_api_key: 'a-strong-secret' # empty disables it +``` + +This key authenticates both the HTTP API and the MCP server (`/mcp`) without a +login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`. + +## Pitfalls + +- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running + usually means the user isn't in the `docker` group → + `sudo usermod -aG docker ` and restart in a new shell. +- Box root host/container path mismatch breaks sandbox container creation. +- Don't commit a non-empty `api.global_api_key` to version control. diff --git a/skills/skills/langbot-dev/SKILL.md b/skills/skills/langbot-dev/SKILL.md new file mode 100644 index 0000000..01ee06f --- /dev/null +++ b/skills/skills/langbot-dev/SKILL.md @@ -0,0 +1,116 @@ +--- +name: langbot-dev +description: Develop, build, and debug the LangBot core backend and web frontend. Use when working inside the LangBot repository — backend (Python/Quart, src/langbot/pkg), the Vite/React web UI, HTTP API controllers/services, Alembic migrations, or the MCP server. Covers the dev environment (uv, pnpm), repo layout, the API auth model (user token / API key / global key), adding API endpoints, and the rule that API changes must update the MCP server and skills. Triggers on "langbot backend", "langbot dev", "langbot api", "add langbot endpoint", "langbot migration". +--- + +# LangBot Core Development + +This skill covers developing the LangBot core (the main repo), distinct from +plugin development (see `langbot-plugin-dev`) and deployment (`langbot-deploy`). + +## Stack + +- **Backend**: Python `>=3.11,<4.0`, deps via `uv`. Framework: **Quart** (async + Flask). Serves the HTTP API + pre-built web UI on `http://127.0.0.1:5300`. +- **Frontend** (`web/`): **Vite + React Router 7 + shadcn/ui + Tailwind**, + managed by `pnpm`. Dev server on `:3000`. (NOT Next.js — `dev` script is `vite`.) + +## Dev environment + +```bash +# Backend +pip install uv +uv sync --dev +uv run main.py # API + UI on http://127.0.0.1:5300 + +# Frontend (separate terminal) +cd web +cp .env.example .env +pnpm install +pnpm dev # http://127.0.0.1:3000 (reads VITE_API_BASE_URL) + +# Lint/format hooks (CI runs the same checks) +uv run pre-commit install +``` + +First run generates `data/config.yaml`; DB defaults to SQLite (PostgreSQL +supported). Migrations run automatically on startup. + +## Repo layout (key paths) + +``` +src/langbot/ +├── __main__.py # entrypoint, CLI flags (--standalone-runtime/-box/--debug) +├── pkg/ +│ ├── api/ +│ │ ├── http/ # Quart controllers + services +│ │ │ ├── controller/groups/ # route groups (@group.group_class) +│ │ │ └── service/ # business logic (called by controllers AND MCP) +│ │ └── mcp/ # MCP server (server.py = tools, mount.py = ASGI dispatch) +│ ├── core/ # app bootstrap, stages, task manager +│ ├── platform/ provider/ pipeline/ plugin/ box/ skill/ rag/ vector/ +│ ├── command/ persistence/ storage/ config/ entity/ telemetry/ +│ └── templates/config.yaml # config template (top-level: api, system, plugin, box, space...) +├── web/ # Vite SPA +└── docker/ # compose deployment +``` + +## HTTP API auth model + +Route auth is declared per-route via `AuthType` in +`pkg/api/http/controller/group.py`: + +- `NONE` — public. +- `USER_TOKEN` — web UI JWT (`Authorization: Bearer `). +- `API_KEY` — `X-API-Key` or `Authorization: Bearer `. +- `USER_TOKEN_OR_API_KEY` — either. + +API keys are verified by `apikey_service.verify_api_key()`, which accepts: +1. the **global key** from `config.yaml` `api.global_api_key` (no DB, no login, + no `lbk_` prefix required), then +2. **web-UI keys** (DB-stored, `lbk_` prefix). + +Route groups self-register via `@group.group_class(name, path)` and are +discovered by `importutil.import_modules_in_pkg`. + +## Adding an API endpoint + +1. Add/extend a controller in `pkg/api/http/controller/groups/` and the matching + service method in `pkg/api/http/service/`. +2. Pick the right `AuthType`. +3. **If the endpoint should be agent-accessible, add/adjust the matching MCP tool + in `pkg/api/mcp/server.py` and update the `langbot-mcp-ops` skill.** API and + MCP surface must stay aligned (see `AGENTS.md`). +4. Update `docs/service-api-openapi.json` if you maintain the OpenAPI overview. + +## Database migrations (Alembic) + +Single migration set supports SQLite + PostgreSQL. Files in +`src/langbot/pkg/persistence/alembic/versions/`. + +```bash +# From project root (needs data/config.yaml) +uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description" +``` + +## Standards + +- All code comments/docstrings in **English**; user-facing strings need **i18n** + (`en_US` + `zh_Hans` minimum, `ja_JP` where present). +- Consider toC and toB compatibility + security. +- Commit format: `(): ` (feat/fix/docs/refactor/...). + +## Tests + +```bash +uv run pytest tests/unit_tests -q # unit tests +uv run pytest tests/unit_tests/api -q # API service tests +uv run python tests/manual/mcp_smoke.py # MCP server e2e smoke +``` + +## See also + +- `langbot-plugin-dev` — plugin SDK / runtime development. +- `langbot-testing` — WebUI/e2e QA harness (`bin/lbs`). +- `langbot-deploy` — Docker/compose deployment + config. +- `langbot-mcp-ops` — operating the LangBot MCP server. diff --git a/skills/skills/langbot-eba-adapter-dev/SKILL.md b/skills/skills/langbot-eba-adapter-dev/SKILL.md new file mode 100644 index 0000000..b0fd180 --- /dev/null +++ b/skills/skills/langbot-eba-adapter-dev/SKILL.md @@ -0,0 +1,301 @@ +--- +name: langbot-eba-adapter-dev +description: Build, refactor, and test LangBot platform adapters for the Event-Based Agents architecture. Use when adding or migrating Telegram, Discord, or other messaging platform adapters to the EBA adapter layout, validating unified event/message conversion, writing live adapter probes, or using standalone plugin runtime plus Computer Use for end-to-end platform testing. +--- + +# LangBot EBA Adapter Development + +Use this skill when implementing or reviewing a LangBot platform adapter under the Event-Based Agents architecture. + +## Controlling a running instance via MCP + +Beyond writing code, you can **drive a live LangBot instance over MCP** — no raw +HTTP needed. Two MCP servers exist (both reuse existing API keys; see `AGENTS.md`): + +- **LangBot instance** — `http://:5300/mcp` (auth: web-UI `lbk_` key or the + `api.global_api_key` from `config.yaml`). Manage bots, pipelines, models, + knowledge bases, and skills. See the **`langbot-mcp-ops`** skill. +- **LangBot Space marketplace** — `https://space.langbot.app/mcp` (auth: Personal + Access Token). Search plugins / MCP servers / skills. See the + **`langbot-space-ops`** skill. + +> Any change to an agent-accessible HTTP API endpoint must keep the matching MCP +> tool and these skills in sync. + +## Core Rule + +Do not let platform-native event or message shapes leak into LangBot's common path. Each adapter must convert incoming SDK objects into unified EBA entities before dispatch: + +- Events: `langbot_plugin.api.entities.builtin.platform.events` +- Message chains: `langbot_plugin.api.entities.builtin.platform.message.MessageChain` +- Users/groups/members: `langbot_plugin.api.entities.builtin.platform.entities` +- Raw platform objects may remain only in `source_platform_object` for debugging or platform-specific escape hatches. + +## Start Here + +1. Read the EBA design docs in `LangBot/docs/event-based-agents/`. +2. Read the architecture-level acceptance checklist before writing or validating code: + - `LangBot/docs/event-based-agents/adapters/acceptance-checklist.md` +3. Read the current reference adapter before writing code. Prefer Telegram first: + - `LangBot/src/langbot/pkg/platform/adapters/telegram/` + - `LangBot/docs/event-based-agents/adapters/telegram.md` +4. Read the legacy source adapter for the target platform: + - `LangBot/src/langbot/pkg/platform/sources/.py` + - `LangBot/src/langbot/pkg/platform/sources/.yaml` +5. Inspect SDK entity definitions in `langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/platform/`. +6. Search before assuming APIs. Platform SDKs change often. + +## Adapter Layout + +Create one directory per adapter: + +```text +LangBot/src/langbot/pkg/platform/adapters// +├── __init__.py +├── adapter.py +├── api_impl.py +├── event_converter.py +├── manifest.yaml +├── message_converter.py +├── platform_api.py +├── types.py +└── .svg +``` + +Add optional helpers such as `voice.py` only when the platform has a real domain-specific surface. + +Ensure `pyproject.toml` package data includes adapter assets: + +```toml +package-data = { "langbot" = ["templates/**", "pkg/platform/sources/*", "pkg/platform/adapters/**", ...] } +``` + +## Implementation Checklist + +- `manifest.yaml` declares `metadata.name`, config schema, supported events, common APIs, and platform-specific APIs. +- `adapter.py` creates the platform client, subscribes to native events, filters self/bot loops where appropriate, calls `event_converter.target2yiri(...)`, then dispatches the EBA event. +- `event_converter.py` maps native events to EBA event classes such as `MessageReceivedEvent`, `MessageEditedEvent`, `MessageDeletedEvent`, `MessageReactionEvent`, `MemberJoinedEvent`, `BotInvitedToGroupEvent`, and `PlatformSpecificEvent`. +- `message_converter.py` maps native messages to `MessageChain`, and maps `MessageChain` back to the platform send format. +- `api_impl.py` implements common EBA APIs: send, reply, edit, delete, forward, user/group/member lookup, moderation, upload/file URL, leave group. +- `platform_api.py` keeps platform-specific calls behind `call_platform_api(action, params)`. +- Unsupported common APIs must raise explicit SDK platform errors such as `NotSupportedError`; do not silently no-op. +- Destructive APIs such as kick, ban, leave, delete, or moderation must be gated in live tests and documented. + +## Conversion Contract + +For message events, the common shape should look like this regardless of platform: + +```python +platform_events.MessageReceivedEvent( + type="message.received", + adapter_name="", + message_id=, + message_chain=platform_message.MessageChain([...]), + sender=platform_entities.User(...), + chat_type=platform_entities.ChatType.PRIVATE or ChatType.GROUP, + chat_id=, + group=platform_entities.UserGroup(...) or None, + source_platform_object=, +) +``` + +Message content should use common components: + +- `Source` for original message id/time when available. +- `Plain` for text. +- `At` / `AtAll` for mentions. +- `Image`, `Voice`, `File` for media. +- `Forward` only when the platform can represent or emulate it safely. + +If a platform event cannot cleanly map to a common event, emit `PlatformSpecificEvent` with a compact `action` and structured `data`. + +## Unit Tests + +Add focused tests under `LangBot/tests/unit_tests/platform/test__eba_adapter.py`. + +Cover at least: + +- Manifest supported events match adapter `supported_events()`. +- Manifest supported APIs match adapter `supported_apis()`. +- Platform API map matches manifest actions. +- Dispatcher chooses the most specific EBA listener. +- Message converter maps every supported common component both directions where possible: + - `Source` + - `Plain` + - `At` + - `AtAll` + - `Image` + - `Voice` + - `File` + - `Quote` + - `Face` + - `Forward` + - `Unknown` + - mixed chains preserving order +- Event converter maps message received/edited/deleted/reaction, raw uncached gateway events, member events, and bot join/leave events. +- Send/reply methods pass correct platform kwargs and return `MessageResult`. + +Run the existing reference adapter tests too: + +```bash +cd LangBot +uv run pytest tests/unit_tests/platform/test__eba_adapter.py tests/unit_tests/platform/test_telegram_eba_adapter.py +uv run python -m py_compile tests/e2e/live__eba_probe.py +git diff --check +``` + +## Live Test Workflow + +Direct adapter live probes are useful diagnostics, but they are not sufficient acceptance evidence for EBA. Treat `tests/e2e/live__eba_probe.py` as an auxiliary tool only. The final adapter record must distinguish: + +- `plugin-e2e-ui`: real SDK plugin through standalone runtime, LangBot core, adapter, and a real/simulator UI action. This can mark an inbound UI item complete. +- `plugin-e2e-protocol`: real SDK plugin through standalone runtime, LangBot core, adapter, and a protocol-boundary injected event. This is useful evidence but must not be claimed as UI coverage. +- `plugin-e2e-outbound`: real SDK plugin calls an API and the bot output is visible in the real/simulator UI. This can mark send/API coverage complete. +- `adapter-live`: direct adapter probe connected to a real/simulator endpoint. This is auxiliary only. +- `unit`: mocked conversion/API-shape coverage. This is auxiliary only. +- `not-supported`: platform protocol or SDK has no equivalent. Must include the reason. +- `blocked`: intended capability could not be verified. This is not complete. + +Write a live probe in `LangBot/tests/e2e/live__eba_probe.py`. It should: + +1. Read token/client ids from environment variables or CLI args. +2. Start the adapter directly. +3. Register an EBA listener and write JSONL evidence to `LangBot/data/temp/`. +4. Wait for a real user/platform event instead of fabricating the entrypoint. +5. Exercise common APIs and `call_platform_api` actions. +6. Observe returned gateway events for edit/delete/reaction/member/bot lifecycle where available. +7. Print a summary containing passed, failed, skipped, and observed event types. +8. Redact or avoid printing secrets. +9. Keep destructive operations behind flags and run them last. + +Use Computer Use when the user asks for real platform end-to-end coverage. Actually send messages/click reactions in the platform UI or otherwise trigger real user-side events; do not replace that with unit tests. + +For media/component acceptance, keep the direction and trigger source explicit: + +- Real inbound media only counts when a human-side platform UI or simulator UI sends the image/file/voice to the bot and the plugin JSONL records the corresponding common component. +- Bot outbound media only proves `send_message`/adapter send conversion. It does not prove inbound conversion. +- Protocol-boundary injection, such as sending a OneBot event directly into a reverse WebSocket adapter, is useful and should be labelled `plugin-e2e-protocol`, but it must not be reported as UI-level end-to-end media upload. +- If the UI cannot send or upload the media, record the item as `blocked` with the exact client/simulator limitation. + +## Standalone Runtime + Plugin Test + +When validating the whole LangBot EBA path, test with the SDK standalone runtime and a real test plugin. This is the required acceptance path; direct adapter calls do not prove the EBA architecture path. + +The required path is: + +```text +Real platform / simulator UI + -> platform SDK native event + -> adapter event converter + -> unified EBA event/entity/message types + -> LangBot core event dispatch + -> standalone SDK runtime + -> real test plugin listener + -> plugin calls platform APIs through SDK + -> LangBot core API dispatch + -> adapter API implementation + -> real platform / simulator UI +``` + +Typical shape: + +```bash +# Terminal 1, SDK repo +cd langbot-plugin-sdk +uv run python -m langbot_plugin.cli.__init__ rt \ + --debug-only \ + --ws-control-port 5400 \ + --ws-debug-port 5401 \ + --skip-deps-check + +# Terminal 2, LangBot repo +cd LangBot +export PYTHONPATH=/absolute/path/to/langbot-plugin-sdk/src:${PYTHONPATH:-} +uv run main.py --standalone-runtime + +# Terminal 3, plugin directory +export DEBUG_RUNTIME_WS_URL=ws://127.0.0.1:5401/plugin/ws +export EBA_PROBE_LOG=/absolute/path/to/LangBot/data/temp/_eba_plugin_probe.jsonl +export EBA_PROBE_API=1 +export EBA_PROBE_COMPONENT_SWEEP=1 +export EBA_PROBE_PLATFORM_API=1 +uv --project /absolute/path/to/langbot-plugin-sdk run python -m langbot_plugin.cli.__init__ run +``` + +Use an EBA probe plugin that subscribes to all relevant EBA event classes and runs SDK API calls after the first `MessageReceived`. + +The plugin evidence should be JSONL and include: + +- event class and `event.type` +- adapter name +- chat type and chat ID +- sender/user/group IDs with secrets redacted +- `bot_uuid` and `adapter_name`, proving LangBot filled common routing fields before plugin dispatch +- received `message_chain` component list +- API action name, input summary, result or error +- unsupported or blocked reason when an item is skipped + +For full adapter acceptance, enable both probe sweeps: + +- `EBA_PROBE_COMPONENT_SWEEP=1` sends the required outbound message components through `send_message`. +- `EBA_PROBE_PLATFORM_API=1` calls common safe APIs plus selected `call_platform_api` actions for the adapter. + +The SDK must support `plugin.call_platform_api(bot_uuid, action, params)` for platform-specific acceptance. If the SDK cannot call a platform-specific action from the plugin, the adapter cannot be fully accepted even if direct adapter probes pass. + +## Required EBA Acceptance Coverage + +Before marking an adapter migrated, fill out an adapter record against `LangBot/docs/event-based-agents/adapters/acceptance-checklist.md`. + +At minimum, the record must cover these categories: + +- Message receive component tests through `plugin-e2e-ui`: `Source`, `Plain`, `At`, `AtAll`, `Image`, `Voice`, `File`, `Quote`, `Face`, `Forward`, `Unknown`, and mixed chains where the platform supports them. Protocol-only receive evidence must be labelled `plugin-e2e-protocol`. +- Message send component tests through `plugin-e2e-outbound`: `Plain`, `At`, `AtAll`, `Image`, `Voice`, `File`, `Quote`, `Face`, `Forward`, and mixed chains where the platform supports them. +- Every event declared in `manifest.yaml -> spec.supported_events`. +- Every common API declared in `manifest.yaml -> spec.supported_apis.required` and `optional`. +- Every action declared in `manifest.yaml -> spec.platform_specific_apis`. +- Compatibility tests for manifest declarations, legacy message listener fallback, EBA listener specificity, bot self-message filtering, and `source_platform_object` reply/debug behavior. + +Do not declare an event or API in the manifest unless it has an implementation path and an acceptance entry. If a platform or simulator lacks a capability, document it as `not-supported` or `blocked` rather than silently omitting the test. + +## Common Pitfalls + +- `get_bots()` may return bot dictionaries, not UUID strings. Probe plugins should select an enabled dict and pass `bot["uuid"]` to `get_bot_info()` and `send_message()`. +- Make sure the probe subscribes to every event you claim to verify. Missing `MessageDeleted` subscription can make a working adapter look untested. +- Some platforms emit both cached and raw gateway events, producing duplicate evidence for delete/reaction. Count this explicitly; do not treat duplicates as failure unless semantics differ. +- Self-message filtering is platform-specific. Filter bot-originated `message.received` loops, but do not accidentally filter edit/delete events needed for bot-owned API probes. +- Reaction events may be filtered for bot self reactions. To test user reaction add/remove, use real UI interaction or a real user token path if permitted. +- File uploads usually happen as message attachments. A standalone `upload_file` API may need to be `NotSupportedError`. +- Live probes should not leak bot tokens through command output, logs, docs, or final answers. +- Discord requires privileged intents for message content and members. Missing intents can look like converter bugs. +- Telegram Bot API exposes only limited member lists; document capability gaps. +- Do not mark moderation APIs verified unless they ran against a disposable target member/bot. +- If `leave_group` is tested, run it last because the test bot will be removed from the server/group. +- Restore local LangBot DB/test state after live runs if you enabled temporary bots or changed plugin settings. + +## Documentation Record + +Add or update `LangBot/docs/event-based-agents/adapters/.md` in the same style as Telegram: + +- Status and adapter directory. +- Configuration table matching manifest fields. +- Supported EBA event list. +- Common API table with support and limitations. +- `call_platform_api` action list. +- Receive component table with evidence level per component. +- Send component table with evidence level per component. +- Event table with evidence level per event. +- Common API table with evidence level per API. +- Platform-specific API table with evidence level per action. +- Live test record with exact date, endpoint/simulator, standalone runtime command, test plugin path/name, JSONL evidence path, channel/group type, observed events, APIs exercised, destructive operations, and skipped items. + +Be honest. Put untested or skipped APIs in the document with the reason. Do not imply full parity when a platform cannot provide the same information density. + +## Before Finishing + +- Run unit tests and compile the live probe. +- Run the standalone runtime plugin E2E path for every required acceptance item that the platform supports. +- Run `git diff --check`. +- Summarize live JSONL evidence by event type. +- Stop all long-running runtimes and probes. +- Confirm no secrets are staged. +- Leave unrelated untracked files alone. diff --git a/skills/skills/langbot-env-setup/SKILL.md b/skills/skills/langbot-env-setup/SKILL.md new file mode 100644 index 0000000..887a467 --- /dev/null +++ b/skills/skills/langbot-env-setup/SKILL.md @@ -0,0 +1,28 @@ +--- +name: langbot-env-setup +description: Prepare a local LangBot development and testing environment for an AI agent. Use when setting up WSL or Linux development, shared local URL variables, proxy variables, backend/frontend startup, Playwright MCP browser access, GitHub OAuth browser login, persisted Chrome profiles, or future Codex computer-use environment paths. +--- + +# LangBot Environment Setup + +Use this skill when a task needs LangBot to be in a testable state before product testing or development verification. + +## Routing + +- **Shared local variables**: read `../.env` before using URL, path, browser profile, or proxy defaults. +- **Always start here**: read `references/browser-access-selection.md` to choose the browser-control path. +- **LangBot service checks and startup**: read `references/service-startup.md`. +- **Computer Use available**: read `references/computer-use.md`. This path usually needs less browser/MCP setup. +- **No Computer Use, browser automation required**: read `references/playwright-mcp.md`. +- **GitHub OAuth or persisted login profile**: read `references/oauth-browser-profile.md`. +- **WSL-specific notes**: read `references/wsl-notes.md` only when running under WSL. +- **Proxy setup**: read `references/proxy.md` when external login, model provider tests, or package downloads time out. +- **Headless-only automation**: use only after a profile already contains a valid LangBot login. Do not ask the agent to enter GitHub credentials or 2FA. + +## Rules + +- Never handle the user's GitHub password, passkey, recovery code, or 2FA secret. +- For OAuth login, open a visible browser and let the user complete the credential steps. +- Reuse a fixed browser profile path so the agent can later access the logged-in LangBot session. +- Keep environment-specific paths and commands in `references/`, not in this file. +- Treat environment setup as complete only after the target LangBot services are reachable and the browser profile can access the WebUI. diff --git a/skills/skills/langbot-env-setup/references/browser-access-selection.md b/skills/skills/langbot-env-setup/references/browser-access-selection.md new file mode 100644 index 0000000..d1f9821 --- /dev/null +++ b/skills/skills/langbot-env-setup/references/browser-access-selection.md @@ -0,0 +1,15 @@ +# Browser Access Selection + +Choose the lightest browser-control path that can complete the task. + +## Decision Order + +1. If Codex Computer Use, Claude Computer Use, or another visible browser-control tool is available, use `computer-use.md`. +2. If no computer-control tool is available but Playwright MCP is available, use `playwright-mcp.md`. +3. If the browser session must survive restarts or OAuth login is required, also use `oauth-browser-profile.md`. +4. If running under WSL, add `wsl-notes.md`. +5. If external sites or model providers time out, add `proxy.md`. + +## Principle + +Computer Use and Playwright MCP are alternative browser-control paths. Both still need LangBot services to be reachable, so service checks stay in `service-startup.md`. diff --git a/skills/skills/langbot-env-setup/references/computer-use.md b/skills/skills/langbot-env-setup/references/computer-use.md new file mode 100644 index 0000000..f0bd8b1 --- /dev/null +++ b/skills/skills/langbot-env-setup/references/computer-use.md @@ -0,0 +1,20 @@ +# Computer Use Browser Path + +Use this path when Codex Computer Use, Claude Computer Use, or another agent-visible browser-control capability is available. + +## Why This Path Is Simpler + +Computer Use can interact with a visible browser directly, so it usually does not need Playwright MCP configuration or a separate MCP browser bridge. + +## Workflow + +1. Verify LangBot backend/frontend with `service-startup.md`. +2. Open the WebUI in the controlled browser. +3. If login is needed, let the user complete GitHub OAuth. Never handle credentials or 2FA. +4. Keep the browser/profile available for later testing. +5. Hand off to `langbot-testing` after the page shows the logged-in WebUI. + +## Still Required + +- Proxy may still be needed for GitHub OAuth or model provider tests. Use `proxy.md`. +- Persisted profile details may still matter if the computer-control browser is restarted. Use `oauth-browser-profile.md` if login state must survive. diff --git a/skills/skills/langbot-env-setup/references/oauth-browser-profile.md b/skills/skills/langbot-env-setup/references/oauth-browser-profile.md new file mode 100644 index 0000000..b3ac8a9 --- /dev/null +++ b/skills/skills/langbot-env-setup/references/oauth-browser-profile.md @@ -0,0 +1,62 @@ +# OAuth Browser Profile + +Use this reference when LangBot or LangBot Space needs GitHub OAuth login and the agent must reuse the authenticated browser state later. + +Read `skills/.env` first for `LANGBOT_BACKEND_URL`, `LANGBOT_FRONTEND_URL`, `LANGBOT_BROWSER_PROFILE`, `LANGBOT_CHROMIUM_EXECUTABLE`, and proxy defaults. + +## Rules + +- Never handle the user's GitHub password, passkey, recovery code, or 2FA secret. +- Open a visible browser and let the user complete credential steps. +- Reuse a fixed browser profile path. +- Do not print token values. It is acceptable to report localStorage key names. + +## Manual Visible Login Flow + +1. Verify LangBot backend is reachable with `service-startup.md`. +2. Launch a visible Chromium window with the persistent profile: + +```bash +setsid "$LANGBOT_CHROMIUM_EXECUTABLE" \ + --no-sandbox \ + --ozone-platform=x11 \ + --user-data-dir="$LANGBOT_BROWSER_PROFILE" \ + --proxy-server="$LANGBOT_PROXY_SOCKS" \ + --proxy-bypass-list="$LANGBOT_NO_PROXY" \ + "$LANGBOT_BACKEND_URL/login" \ + >/tmp/langbot-visible-chrome.log 2>&1 < /dev/null & +``` + +3. The user completes: + +```text +Login with Space -> Login with GitHub -> GitHub credentials / 2FA -> authorize +``` + +4. The agent can then reuse the same profile for automated checks. + +## Expected Successful State + +After login, LangBot should redirect away from `/login`, for example to a `/home/...` URL on the selected origin. + +Expected visible signals: + +```text +LangBot +Dashboard +Home +Bots +Pipelines +Knowledge +Extensions +``` + +Expected localStorage key names: + +```text +token +userEmail +langbot_language +``` + +If the user logged in on one origin but `LANGBOT_FRONTEND_URL` still shows `/login`, copy only the auth state needed between origins. Do not print token values. diff --git a/skills/skills/langbot-env-setup/references/playwright-mcp.md b/skills/skills/langbot-env-setup/references/playwright-mcp.md new file mode 100644 index 0000000..3e460e2 --- /dev/null +++ b/skills/skills/langbot-env-setup/references/playwright-mcp.md @@ -0,0 +1,30 @@ +# Playwright MCP Browser Path + +Use this path when the agent needs browser automation but no Computer Use browser-control path is available. + +## Known Paths + +- Persistent browser profile: `LANGBOT_BROWSER_PROFILE` from `skills/.env.local` +- Chromium executable: `LANGBOT_CHROMIUM_EXECUTABLE` from `skills/.env.local` +- Codex MCP config: `$CODEX_HOME/config.toml` or the config path used by the active agent. + +## MCP Config + +Keep the profile path fixed so the agent can reuse authenticated state. + +```toml +[mcp_servers.playwright] +command = "npx" +args = ["-y", "@playwright/mcp@latest", "--no-sandbox", "--executable-path", "", "--proxy-server", "", "--proxy-bypass", "localhost,127.0.0.1", "--user-data-dir", ""] +``` + +After changing MCP config, restart Codex so the MCP server is relaunched with the new args. + +## Visible Login + +For OAuth login, Playwright MCP's headless browser is not enough. Launch a visible browser with the same profile and let the user complete login. Use `oauth-browser-profile.md`. + +## Common Failures + +- MCP still uses old args after editing config: restart Codex or kill old `playwright-mcp` processes and restart the session. +- Browser is headless during OAuth: use the visible login command from `oauth-browser-profile.md`. diff --git a/skills/skills/langbot-env-setup/references/proxy.md b/skills/skills/langbot-env-setup/references/proxy.md new file mode 100644 index 0000000..14b2764 --- /dev/null +++ b/skills/skills/langbot-env-setup/references/proxy.md @@ -0,0 +1,30 @@ +# Proxy Setup + +Use this reference when GitHub OAuth, package installation, model provider tests, or external API calls time out. + +Read defaults from `skills/.env` first. + +## Standard Local Proxy + +```bash +export HTTP_PROXY="$LANGBOT_PROXY_HTTP" +export HTTPS_PROXY="$LANGBOT_PROXY_HTTP" +export ALL_PROXY="$LANGBOT_PROXY_SOCKS" +export http_proxy="$LANGBOT_PROXY_HTTP" +export https_proxy="$LANGBOT_PROXY_HTTP" +export all_proxy="$LANGBOT_PROXY_SOCKS" +export NO_PROXY="$LANGBOT_NO_PROXY" +export no_proxy="$LANGBOT_NO_PROXY" +``` + +## Rule + +Keep uppercase and lowercase proxy variables consistent. Different libraries read different names. + +## Checks + +```bash +env | rg -i '^(http|https|all|no)_?proxy=' +curl -I --max-time 8 --proxy "$LANGBOT_PROXY_SOCKS" https://github.com +curl -I --max-time 3 "$LANGBOT_BACKEND_URL" +``` diff --git a/skills/skills/langbot-env-setup/references/service-startup.md b/skills/skills/langbot-env-setup/references/service-startup.md new file mode 100644 index 0000000..b63960c --- /dev/null +++ b/skills/skills/langbot-env-setup/references/service-startup.md @@ -0,0 +1,77 @@ +# Service Startup + +Use this reference for LangBot backend/frontend readiness checks regardless of OS or browser-control method. Read `skills/.env` first and override those defaults with user-provided values or detected running services. + +## Variables + +- `LANGBOT_REPO` +- `LANGBOT_WEB_REPO` +- `LANGBOT_BACKEND_URL` +- `LANGBOT_FRONTEND_URL` +- `LANGBOT_DEV_FRONTEND_URL` + +## Backend + +Start LangBot from the backend repo: + +```bash +cd "$LANGBOT_REPO" +uv run main.py +``` + +Healthy startup includes: + +```text +Running on http://0.0.0.0: +Connected to plugin runtime. +Plugin langbot/local-agent initialized +``` + +Quick check: + +```bash +curl -I --max-time 3 "$LANGBOT_BACKEND_URL/login" +``` + +If `bin/lbs env doctor` reports that `LANGBOT_BACKEND_URL` has no TCP listener, +the backend is not running at the configured host and port. A reachable +standalone frontend on `LANGBOT_FRONTEND_URL` does not prove backend readiness. + +Prefer a visible terminal session while debugging backend startup. Detached +background startup methods can hide early process exits in local agent runs; if +you use one, immediately verify both the process and the listener: + +```bash +ps -eo pid,cmd | rg 'main.py|uv run main|langbot' +ss -ltnp | rg ':5300' +curl -I --max-time 3 "$LANGBOT_BACKEND_URL/login" +``` + +## Frontend + +Start the new frontend from the web repo: + +```bash +cd "$LANGBOT_WEB_REPO" +VITE_API_BASE_URL="$LANGBOT_BACKEND_URL" pnpm dev --host 0.0.0.0 +``` + +Healthy startup includes: + +```text +Local: +``` + +Quick check: + +```bash +curl -I --max-time 3 "$LANGBOT_FRONTEND_URL" +``` + +If `VITE_API_BASE_URL` is missing, Vite still serves the page but frontend API +calls may go to the frontend port instead of the backend port. That produces +false browser failures in login, wizard, pipeline, and Debug Chat cases. + +## Completion Signal + +Environment setup is not complete until the required frontend/backend URLs are reachable and the chosen browser-control path can open the WebUI. diff --git a/skills/skills/langbot-env-setup/references/wsl-notes.md b/skills/skills/langbot-env-setup/references/wsl-notes.md new file mode 100644 index 0000000..6b12b02 --- /dev/null +++ b/skills/skills/langbot-env-setup/references/wsl-notes.md @@ -0,0 +1,36 @@ +# WSL Notes + +Use this reference only for WSL-specific details. Do not put generic LangBot startup or browser-login steps here. + +## Network + +GitHub login and model provider calls may require proxy access from WSL. + +Working proxy form: + +```bash +socks5://127.0.0.1:7890 +``` + +Bypass local LangBot: + +```bash +localhost,127.0.0.1 +``` + +Quick checks: + +```bash +curl -I --max-time 8 --proxy socks5h://127.0.0.1:7890 https://github.com +curl -I --max-time 3 "$LANGBOT_BACKEND_URL" +``` + +## Visible Browser + +If OAuth requires a visible browser, WSL must have a usable display path. If a visible Chromium launch fails, check the local WSL GUI/X11 setup before changing LangBot config. + +## Common Failures + +- `ERR_NETWORK_CHANGED` or GitHub timeout: browser is not using the SOCKS proxy. +- LangBot connection refused: backend is not running or not reachable from WSL. +- User cannot type credentials: browser is headless or not visible to the user. diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md new file mode 100644 index 0000000..94f25a3 --- /dev/null +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -0,0 +1,99 @@ +--- +name: langbot-mcp-ops +description: Operate a LangBot instance through its built-in MCP (Model Context Protocol) server. Use when an AI agent needs to manage LangBot — list/create/update/delete bots, pipelines, models, knowledge bases, MCP servers, and skills — over MCP instead of raw HTTP. Covers the /mcp endpoint, API-key auth (web-UI lbk_ keys and the config.yaml global key), the tool surface, and client configuration. Triggers on "langbot mcp", "manage langbot via mcp", "langbot /mcp", "langbot mcp server". +--- + +# LangBot MCP Operations + +LangBot exposes an **MCP server** so AI agents can manage an instance +programmatically. It mirrors a curated subset of the HTTP service API. + +## Endpoint + +``` +http://:5300/mcp +``` + +Transport: **streamable HTTP** (stateless, JSON responses). Same host/port as +the web UI and HTTP API. + +## Authentication + +Reuses the same API keys as the HTTP API. Send either header: + +``` +X-API-Key: +# or +Authorization: Bearer +``` + +Two kinds of key are accepted: + +1. **Web-UI key** — created in the web UI (sidebar → API Keys), prefixed `lbk_`, + stored in the database. +2. **Global API key** — set in `data/config.yaml` under `api.global_api_key`. + Requires no login session and no DB record; does not need the `lbk_` prefix. + Leave empty to disable. See the `langbot-deploy` skill for config details. + +Requests without a valid key get `401 Unauthorized`. + +## Client configuration + +```json +{ + "mcpServers": { + "langbot": { + "url": "http://:5300/mcp", + "headers": { "X-API-Key": "" } + } + } +} +``` + +## Tool surface + +The tools wrap the LangBot service layer. Current tools (v1): + +| Tool | Purpose | +| --- | --- | +| `get_system_info` | Version, edition, instance id | +| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) | +| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines | +| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers | +| `list_knowledge_bases` / `get_knowledge_base` / `retrieve_knowledge_base` | RAG knowledge bases (incl. semantic search) | +| `list_mcp_servers` | External MCP servers LangBot connects to (as a client) | +| `list_skills` / `get_skill` | Installed skills | + +Mutating tools (`create_*`, `update_*`) take a JSON object matching the same +shape as the corresponding HTTP API request body. Discover resources with the +`list_*` / `get_*` tools before mutating; identifiers are UUIDs. + +## How to use + +1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml). +2. Point your MCP client at `http://:5300/mcp` with the key header. +3. Call `get_system_info` to confirm connectivity. +4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` / + `delete_*` as needed. + +## Implementation & maintenance (for LangBot developers) + +- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service + layer directly, so the MCP surface stays aligned with the API. +- Mount: `src/langbot/pkg/api/mcp/mount.py` — an ASGI dispatcher fronting Quart, + authenticating `/mcp` requests, running the streamable-HTTP session manager. +- Smoke test: `tests/manual/mcp_smoke.py`. + +> When you add, remove, or change an HTTP API endpoint that should be +> agent-accessible, update the corresponding MCP tool **and** this skill. The +> MCP tool surface and the API must stay aligned (see `AGENTS.md`). + +## Pitfalls + +- `/mcp` is the **server** LangBot exposes. The `/api/v1/mcp` routes are the + **client** side (managing external MCP servers LangBot connects to). Don't + confuse them. +- A `401` means the key is wrong, missing, or (for the global key) + `api.global_api_key` is empty in config.yaml. +- The global key is plaintext in config.yaml — only enable it on trusted/internal + deployments and serve over HTTPS. diff --git a/skills/skills/langbot-plugin-dev/SKILL.md b/skills/skills/langbot-plugin-dev/SKILL.md new file mode 100644 index 0000000..d7858c4 --- /dev/null +++ b/skills/skills/langbot-plugin-dev/SKILL.md @@ -0,0 +1,484 @@ +--- +name: langbot-plugin-dev +description: Develop, debug, and test LangBot plugins. Use when creating new LangBot plugins, fixing plugin bugs, setting up a LangBot test environment, or testing plugins via WebSocket. Covers plugin component architecture (EventListener, Command, Tool), the plugin SDK API (invoke_llm, get_llm_models, send_message, plugin storage), common pitfalls, and automated WebSocket-based testing. Triggers on "langbot plugin", "lbp", "GroupChatSummary", "plugin debug", "langbot test". +--- + +# LangBot Plugin Development & Debugging + +## Controlling a running instance via MCP + +Beyond writing code, you can **drive a live LangBot instance over MCP** — no raw +HTTP needed. Two MCP servers exist (both reuse existing API keys; see `AGENTS.md`): + +- **LangBot instance** — `http://:5300/mcp` (auth: web-UI `lbk_` key or the + `api.global_api_key` from `config.yaml`). Manage bots, pipelines, models, + knowledge bases, and skills. See the **`langbot-mcp-ops`** skill. +- **LangBot Space marketplace** — `https://space.langbot.app/mcp` (auth: Personal + Access Token). Search plugins / MCP servers / skills. See the + **`langbot-space-ops`** skill. + +> Any change to an agent-accessible HTTP API endpoint must keep the matching MCP +> tool and these skills in sync. + +## Plugin Architecture + +A LangBot plugin consists of: + +``` +MyPlugin/ +├── manifest.yaml # Plugin metadata, config schema +├── main.py # BasePlugin subclass (entry point, shared state) +├── components/ +│ ├── event_listener/ # Hook pipeline events +│ │ ├── collector.yaml +│ │ └── collector.py +│ ├── commands/ # !command handlers +│ │ ├── mycommand.yaml +│ │ └── mycommand.py +│ └── tools/ # LLM function-call tools +│ ├── mytool.yaml +│ └── mytool.py +``` + +Each component has a `.yaml` (metadata) and `.py` (implementation). + +## README & i18n convention (enforced on the marketplace) + +A plugin published to LangBot Space serves a localized README on its detail page. +The resolver (`langbot-space` `PluginService.GetPluginREADME`) works like this: + +- **Root `README.md` MUST be in English.** It is the default and the fallback — + when no per-language README matches the viewer's locale, the page serves the + root `README.md`. A non-English root README makes the English/default view show + the wrong language. +- **All other languages live under `readme/README_{lang}.md`** — e.g. + `readme/README_zh_Hans.md`, `readme/README_ja_JP.md`. The 8 supported locales: + `en_US, zh_Hans, zh_Hant, ja_JP, th_TH, vi_VN, es_ES, ru_RU`. +- `manifest.yaml` `metadata.label` / `metadata.description` should carry the same + 8-locale i18n set (`repository` must be a real, alive URL). + +``` +MyPlugin/ +├── manifest.yaml +├── README.md # English (default + fallback) — REQUIRED, must be English +└── readme/ + ├── README_zh_Hans.md + ├── README_zh_Hant.md + ├── README_ja_JP.md + ├── README_th_TH.md + ├── README_vi_VN.md + ├── README_es_ES.md + └── README_ru_RU.md +``` + +`manifest.yaml` (incl. `repository`) is the source of truth — the marketplace +syncs from it, so edit the package and re-publish rather than patching live data. + +## Critical SDK Pitfalls + +### 1. MessageChain is a RootModel — iterate directly + +```python +# ❌ WRONG — MessageChain has no .components attribute +for component in event.message_chain.components: + +# ✅ CORRECT — MessageChain is a Pydantic RootModel, iterate directly +for component in event.message_chain: +``` + +### 2. Message.content must be `list[ContentElement]` or `str`, not a single ContentElement + +```python +from langbot_plugin.api.entities.builtin.provider import message as provider_message + +# ❌ WRONG — single ContentElement +Message(role="user", content=ContentElement.from_text("hello")) + +# ✅ CORRECT — list of ContentElement +Message(role="user", content=[ContentElement.from_text("hello")]) + +# ✅ ALSO CORRECT — plain string +Message(role="user", content="hello") +``` + +### 3. invoke_llm does NOT accept timeout + +```python +# ❌ WRONG +await self.invoke_llm(llm_model_uuid=uuid, messages=msgs, timeout=60) + +# ✅ CORRECT +await self.invoke_llm(llm_model_uuid=uuid, messages=msgs) +``` + +### 4. invoke_llm response.content can be str OR list + +```python +response = await self.invoke_llm(...) +if response.content: + if isinstance(response.content, str): + return response.content + elif isinstance(response.content, list): + parts = [e.text for e in response.content if hasattr(e, "text") and e.text] + return "\n".join(parts) +``` + +### 5. get_llm_models() returns UUIDs + +```python +# Returns list[str] of model UUIDs +models = await self.get_llm_models() +model_uuid = models[0] # First available model UUID +``` + +**Known bug (v4.9.3):** The host handler may return `list[dict]` instead of `list[str]`. If you hit `TypeError: unhashable type: 'dict'` in `invoke_llm`, the fix is in `LangBot/src/langbot/pkg/plugin/handler.py` — change `'llm_models': llm_models` to `'llm_models': [m['uuid'] for m in llm_models]`. + +### 6. invoke_llm parameter is `llm_model_uuid`, NOT `model_uuid` + +```python +# ❌ WRONG — will throw "got an unexpected keyword argument" +await self.invoke_llm(messages=msgs, model_uuid=uuid) + +# ✅ CORRECT +await self.invoke_llm(messages=msgs, llm_model_uuid=uuid) +``` + +### 7. prevent_default() alone does NOT block LLM response + +To fully prevent the default LLM pipeline from responding when your EventListener handles the message, you must call **both**: + +```python +event_context.prevent_default() # Block default behavior +event_context.prevent_postorder() # Block subsequent plugins/pipeline +``` + +Using only `prevent_default()` still allows the LLM to generate a response. + +### 8. get_plugin_storage / set_plugin_storage may throw KeyError: 'owner' + +This is a version mismatch between the SDK and host. Wrap storage calls in try/except: + +```python +try: + data = await self.get_plugin_storage("my_key") +except Exception: + data = None # Fallback gracefully +``` + +### 9. Component YAML must have full structure, not just name/description + +```yaml +# ❌ WRONG — will silently fail to register the component +name: translator +description: + en_US: 'Does stuff' + +# ✅ CORRECT — full component YAML +apiVersion: v1 +kind: EventListener +metadata: + name: translator + label: + en_US: Translator +spec: +execution: + python: + path: translator.py + attr: Translator +``` + +### 10. BasePlugin import path + +```python +# ❌ WRONG +from langbot_plugin.api.definition.base_plugin import BasePlugin + +# ✅ CORRECT +from langbot_plugin.api.definition.plugin import BasePlugin +``` + +## Pipeline Events + +Events the EventListener can hook (from most general to most specific): + +| Event | When | +|---|---| +| `GroupMessageReceived` | **Any** group message arrives (before trigger rules) | +| `PersonMessageReceived` | **Any** private message arrives | +| `GroupNormalMessageReceived` | Group message passes trigger rules, going to LLM | +| `PersonNormalMessageReceived` | Private message going to LLM | +| `GroupCommandSent` | Group message matched as command | +| `PersonCommandSent` | Private message matched as command | +| `NormalMessageResponded` | LLM generated a response | +| `PromptPreProcessing` | About to build LLM context | + +**Key insight:** `*MessageReceived` fires for ALL messages regardless of trigger rules. `*NormalMessageReceived` only fires for messages that match the pipeline's trigger rules (e.g., @bot, prefix, random%). Use `*MessageReceived` for message collection/logging. + +## EventContext API + +```python +@self.handler(events.GroupMessageReceived) +async def on_msg(event_context: context.EventContext): + event = event_context.event + event.launcher_id # Group ID + event.sender_id # Sender ID + event.message_chain # MessageChain (iterate directly) + + # Reply to the current conversation + await event_context.reply(MessageChain([Plain(text="hello")])) + + # Block default pipeline behavior + event_context.prevent_default() + + # Block subsequent plugins + event_context.prevent_postorder() +``` + +## Setting Up a Test Environment + +### Deploy via Docker (GitOps + Portainer) + +See `references/test-env-setup.md` for full deployment steps. + +Quick summary: +1. Create `docker-compose.yaml` in `server-deploy` repo +2. Deploy via Portainer git repository method +3. Set up admin account via `/api/v1/user/init` POST +4. Configure LLM provider and model via API +5. Copy plugin to `data/plugins/` directory + +### WebSocket Testing + +LangBot's WebUI chat uses WebSocket. Connect to test message flow: + +``` +ws://:/api/v1/pipelines//ws/connect?session_type=group +``` + +- `session_type=group` for group chat simulation +- `session_type=person` for private chat (always triggers pipeline) + +**Requires Origin header** to pass CORS: +```javascript +const ws = new WebSocket(url, { + headers: { Origin: 'https://your-langbot-domain' } +}); +``` + +Send messages: +```json +{"type": "message", "message": [{"type": "Plain", "text": "hello"}]} +``` + +Receive: +- `{"type": "connected", ...}` — connection established +- `{"type": "user_message", "data": {...}}` — echo of sent message +- `{"type": "response", "data": {"content": "...", "is_final": true/false}}` — bot reply (streamed) + +### Group Trigger Rules + +Group messages only enter the pipeline if trigger rules are met: + +```json +{ + "group-respond-rules": { + "at": true, // Respond when @bot + "prefix": ["ai"], // Respond to messages starting with "ai" + "random": 0.0, // Probability of responding to any message (0.0-1.0) + "regexp": [] // Regex patterns + } +} +``` + +For testing, set `random: 1.0` via PUT `/api/v1/pipelines/` to respond to all messages. + +**Important:** EventListener hooks like `GroupMessageReceived` fire regardless of trigger rules. Only the LLM processing (`GroupNormalMessageReceived` and beyond) requires trigger rules. + +### Plugin Hot-Reload + +There is **no hot-reload**. After changing plugin files: + +```bash +docker restart +# Wait ~5 seconds for plugin to re-mount +``` + +The main LangBot container does NOT need restart for plugin changes — only the runtime container. + +## API Quick Reference + +### Admin Setup + +```bash +# Initialize admin account (first time only) +curl -X POST $BASE/api/v1/user/init \ + -H "Content-Type: application/json" \ + -d '{"user":"admin@test.com","password":"test123"}' + +# Login +curl -X POST $BASE/api/v1/user/auth \ + -H "Content-Type: application/json" \ + -d '{"user":"admin@test.com","password":"test123"}' +# Returns: {"data":{"token":"eyJ..."}} +``` + +### Provider & Model Setup + +```bash +# Create provider +curl -X POST $BASE/api/v1/provider/providers \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"MyProvider","requester":"new-api-chat-completions","base_url":"https://api.example.com/v1","api_keys":["sk-xxx"]}' + +# Create LLM model +curl -X POST $BASE/api/v1/provider/models/llm \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"gpt-4o-mini","provider_uuid":"","abilities":["chat","tool-use"]}' + +# List models +curl $BASE/api/v1/provider/models/llm -H "Authorization: Bearer $TOKEN" +``` + +### Pipeline Config + +```bash +# Get pipeline +curl $BASE/api/v1/pipelines -H "Authorization: Bearer $TOKEN" + +# Update pipeline (e.g., set model, modify trigger rules) +curl -X PUT $BASE/api/v1/pipelines/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '' +``` + +## Plugin Config Types + +Supported `type` values in `manifest.yaml` `spec.config`: + +| Type | Description | Value | +|---|---|---| +| `string` | Text input | string | +| `int` / `integer` | Number input | int | +| `float` | Decimal input | float | +| `bool` / `boolean` | Toggle | bool | +| `select` | Dropdown (needs `options`) | string | +| `prompt-editor` | Multi-line prompt editor | string | +| `llm-model-selector` | LLM model picker UI | UUID string | +| `bot-selector` | Bot picker UI | UUID string | + +Example — let users choose which model the plugin uses: + +```yaml +spec: + config: + - name: model + type: llm-model-selector + label: + en_US: 'LLM Model' + zh_Hans: 'LLM 模型' + description: + en_US: 'Select the LLM model. Falls back to first available if not set.' + zh_Hans: '选择 LLM 模型。未设置时使用第一个可用模型。' + required: false +``` + +Read config in plugin code: + +```python +model_uuid = self.get_config().get("model") +``` + +## Container Restart Timing + +After plugin file changes, **only the runtime container needs restart**: + +```bash +docker restart langbot-test-runtime +# Wait ~15 seconds before testing +``` + +**When to restart both (runtime first, then host):** +- Added/removed Command or Tool components (host caches component lists) +- Changed `manifest.yaml` structure + +```bash +docker restart langbot-test-runtime +sleep 8 +docker restart langbot-test +sleep 8 +``` + +**⚠️ Do NOT restart both simultaneously** — the host may connect before plugins are mounted, causing 502 errors or missing plugin registrations. + +## Debugging Checklist + +When a plugin doesn't work: + +1. **Check runtime logs**: `docker logs ` — look for mount/init errors +2. **Check host logs**: `docker logs ` — look for pipeline processing errors +3. **Verify plugin loaded**: `GET /api/v1/plugins` — should list your plugin +4. **Test person mode first**: `session_type=person` always triggers pipeline, isolating trigger rule issues +5. **Check trigger rules**: Group mode requires @bot, prefix match, or random% to enter pipeline +6. **Verify model configured**: Pipeline's `config.ai.local-agent.model.primary` must point to a valid model UUID with working API keys + +## Publishing Plugins + +After testing, publish via `lbp publish`: + +```bash +cd /path/to/MyPlugin +lbp publish +``` + +This builds `.lbpkg` and uploads to Space marketplace as a draft. Then go to https://space.langbot.app/market to upload screenshots and submit for review. + +**Prerequisite:** Must be logged in via `lbp login --token lbpat_xxx` (PAT from Space profile page). + +## Reference: EventListener-Only Plugin Pattern + +For plugins that react to messages without commands or tools (e.g., auto-summarize URLs, collect messages, translate): + +``` +MyPlugin/ +├── manifest.yaml # Only EventListener in spec.components +├── main.py # BasePlugin with shared logic (fetch, LLM calls) +├── components/ +│ └── event_listener/ +│ ├── detector.yaml +│ └── detector.py +└── requirements.txt +``` + +**manifest.yaml** — only declare EventListener: +```yaml +spec: + components: + EventListener: + fromDirs: + - path: components/event_listener/ +``` + +**detector.py** — hook `*MessageReceived`, extract text, process, reply: +```python +@self.handler(events.PersonMessageReceived) +async def on_msg(event_context: context.EventContext): + event = event_context.event + text_parts = [] + for component in event.message_chain: + if isinstance(component, platform_message.Plain): + text_parts.append(component.text) + text = "".join(text_parts).strip() + + if should_handle(text): + event_context.prevent_default() + event_context.prevent_postorder() + result = await self.plugin.process(text) + await event_context.reply(platform_message.MessageChain([ + platform_message.Plain(text=result) + ])) +``` + +**Key:** Access shared plugin logic via `self.plugin` (the BasePlugin instance). diff --git a/skills/skills/langbot-plugin-dev/references/test-env-setup.md b/skills/skills/langbot-plugin-dev/references/test-env-setup.md new file mode 100644 index 0000000..c4fa203 --- /dev/null +++ b/skills/skills/langbot-plugin-dev/references/test-env-setup.md @@ -0,0 +1,116 @@ +# Test Environment Setup + +## Docker Compose (GitOps) + +Create in `server-deploy` repo under `servers//langbot-test/docker-compose.yaml`: + +```yaml +version: "3" +services: + langbot_plugin_runtime: + image: rockchin/langbot:latest + container_name: langbot-test-runtime + volumes: + - /opt/docker-data/langbot-test/data/plugins:/app/data/plugins + ports: + - "5411:5401" + restart: on-failure + environment: + - TZ=Asia/Shanghai + command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"] + networks: + - langbot_test_network + + langbot: + image: rockchin/langbot:latest + container_name: langbot-test + volumes: + - /opt/docker-data/langbot-test/data:/app/data + ports: + - "5310:5300" + restart: on-failure + depends_on: + - langbot_plugin_runtime + environment: + - TZ=Asia/Shanghai + networks: + - langbot_test_network + +networks: + langbot_test_network: + driver: bridge +``` + +## Post-Deploy Configuration + +After first start, LangBot auto-generates `data/config.yaml`. You need to update `plugin.runtime_ws_url` to match the runtime container name: + +```bash +# On the host, edit config +sed -i 's|ws://localhost:5400/control/ws|ws://langbot-test-runtime:5400/control/ws|' \ + /opt/docker-data/langbot-test/data/config.yaml +docker restart langbot-test +``` + +## Installing a Plugin + +Copy plugin directory to `data/plugins/` on the host: + +```bash +scp -r MyPlugin/ user@host:/opt/docker-data/langbot-test/data/plugins/MyPlugin/ +docker restart langbot-test-runtime # Runtime picks up new plugins on restart +``` + +## Caddy Reverse Proxy (Optional) + +If testing externally, add to Caddyfile on the same host: + +``` +langbot-test.example.com { + reverse_proxy langbot-test:5300 +} +``` + +Then reload: `docker exec caddy caddy reload --config /etc/caddy/Caddyfile` + +The WebSocket endpoint works through Caddy without special config. + +## WebSocket Test Script (Node.js) + +```javascript +const WebSocket = require('ws'); + +const PIPELINE_UUID = ''; +const BASE = 'wss://langbot-test.example.com'; +const URL = `${BASE}/api/v1/pipelines/${PIPELINE_UUID}/ws/connect?session_type=group`; + +const ws = new WebSocket(URL, { + headers: { Origin: BASE } +}); + +const send = (text) => { + ws.send(JSON.stringify({ + type: 'message', + message: [{ type: 'Plain', text }] + })); + console.log('[SENT]', text); +}; + +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'connected') { + console.log('Connected!'); + // Send test messages + send('Message 1'); + setTimeout(() => send('Message 2'), 500); + setTimeout(() => send('!summary'), 2000); + } else if (msg.type === 'response' && msg.data?.is_final) { + console.log('[BOT]', msg.data.content); + } +}); + +ws.on('error', (e) => console.error('Error:', e.message)); +setTimeout(() => { ws.close(); process.exit(); }, 60000); +``` + +Requires: `npm install ws` diff --git a/skills/skills/langbot-skills-maintenance/SKILL.md b/skills/skills/langbot-skills-maintenance/SKILL.md new file mode 100644 index 0000000..4ba8fe6 --- /dev/null +++ b/skills/skills/langbot-skills-maintenance/SKILL.md @@ -0,0 +1,40 @@ +--- +name: langbot-skills-maintenance +description: Maintain the langbot-skills repository with low duplication. Use when adding, editing, or auditing LangBot skills, references, cases, troubleshooting entries, indexes, or periodic entropy-control checks for this skills repository. +--- + +# LangBot Skills Maintenance + +Use this skill before changing reusable assets in this repository. + +## Workflow + +1. Read `AGENTS.md`, `skills/.env`, and the relevant existing skill files. +2. Classify the change: + - `SKILL.md` for routing and concise operating rules. + - `references/*.md` for canonical detailed workflows. + - `cases/*.yaml` for executable test-plan skeletons. + - `suites/*.yaml` for reusable groups of case ids. + - `fixtures/fixtures.json` for deterministic fixture readiness metadata. + - `reports/evidence//automation-result.json` as automation output and `reports/evidence//result.json` as final judgment output; neither is a catalog asset to commit. + - `troubleshooting/*.yaml` for one reusable failure mode. +3. Search existing assets before adding new files: + - `rg "" skills` + - `bin/lbs case list` + - `bin/lbs suite list` + - `bin/lbs fixture list` +4. Put detail in one canonical place and link to it from cases or routing bullets. +5. Run the checks in `AGENTS.md` after edits. + +## Entropy Rules + +- Prefer extending an existing reference or troubleshooting entry when the root cause is the same. +- Keep cases short: setup, action, evidence, pass/fail checks. Do not paste long prompts or debug transcripts when a reference exists. +- Put machine-checkable inputs in `env`, `automation_env`, or fixtures; put operator-confirmed assumptions in `preconditions` so `test plan` can surface `manual_check`. +- Keep suites short: title, intent, tags, and ordered case ids. Do not duplicate case steps inside a suite. +- Keep fixture manifests factual: id, title, path, kind, and related case ids. Do not encode environment-specific absolute paths. +- Keep troubleshooting entries narrow: symptoms, patterns, likely causes, fixes, related assets. +- Do not hardcode local ports, browser profile paths, secrets, tokens, or provider keys. +- Use `bin/lbs index --check` to verify the committed index is current without writing it; run `bin/lbs index` when the index needs regeneration. + +For periodic repository audits, read `references/curation-workflow.md`. diff --git a/skills/skills/langbot-skills-maintenance/references/curation-workflow.md b/skills/skills/langbot-skills-maintenance/references/curation-workflow.md new file mode 100644 index 0000000..c40f25d --- /dev/null +++ b/skills/skills/langbot-skills-maintenance/references/curation-workflow.md @@ -0,0 +1,70 @@ +# Curation Workflow + +Use this checklist when the repository starts accumulating repeated cases, copied steps, or overlapping troubleshooting entries. + +## Audit Pass + +1. Inspect the current surface: + - `bin/lbs case list` + - `bin/lbs case list --json --priority p0 --automation` + - `bin/lbs case list --ready` + - `bin/lbs case list --machine-ready` + - `bin/lbs suite list` + - `bin/lbs fixture list` + - `rg "sandbox|provider|pipeline|plugin|knowledge|mcp" skills` + - `rg "If .* fails|Known Pitfalls|Debug Chat|/api/v1" skills` +2. Group nearby assets by intent, not by file path: + - user-facing scenario + - backend or provider dependency + - failure signature + - pass/fail evidence +3. Pick one canonical owner: + - stable procedures belong in `references/` + - deterministic files and packages belong in `fixtures/` plus `fixtures/fixtures.json` + - repeated failure signatures belong in `troubleshooting/` + - runnable QA paths belong in `cases/` + - reusable groups of QA paths belong in `suites/` + - skill entry points belong in `SKILL.md` + +## Merge Or Split + +Merge when two files share the same trigger, root cause, and fix. Keep the stronger id and move missing patterns into it. + +Split when a file mixes unrelated failure modes or requires different fixes. Each troubleshooting id should map to one diagnosis path. + +Move repeated step lists out of cases and into a reference when more than one case would need the same prompt, UI path, or log interpretation. + +Add or update a suite when developers repeatedly run the same ordered group of cases. Do not copy case steps into suites; use `bin/lbs suite plan ` to expand the group. +Use `bin/lbs suite start ` and `bin/lbs suite report --evidence-dir ` when validating that a suite is operational end to end. + +Add or update `fixtures/fixtures.json` when a case depends on a deterministic file, plugin package, or local test server. The manifest should use repo-relative paths under the owning skill and should not contain machine-local absolute paths. + +When adding Debug Chat Playwright automation, reuse `scripts/e2e/lib/debug-chat.mjs` for navigation, prompt send, response leaf matching, and known failure classification. Keep case-specific prompts and expected sentinels in case YAML automation fields when possible. + +## Case Review + +For every changed case: + +1. Ensure `steps` describe what to execute, not every command in the underlying implementation. +2. Ensure `checks` contain observable UI, log, network, or filesystem evidence. +3. Ensure `diagnostics` are fallback investigation hints, not pass criteria. +4. Ensure `priority`, `risk`, `ci_eligible`, and `evidence_required` match the actual repeatability and evidence burden. +5. Put must-have env vars in `env` / `automation_env`; put one-of choices such as URL-or-name in `env_any` / `automation_env_any`. +6. Ensure linked `skills` and `troubleshooting` ids exist. +7. Run: + + ```bash + bin/lbs validate + bin/lbs index --check + bin/lbs index + bin/lbs test plan + ``` + +## Final Gate + +Before handing off: + +- `git diff --stat` should show a focused change set. +- `skills.index.json` should be regenerated only by `bin/lbs index`. +- No new asset should contain local credentials, OAuth tokens, API keys, or copied localStorage values. +- The final note should say which checks ran and which cases or troubleshooting ids changed. diff --git a/skills/skills/langbot-space-ops/SKILL.md b/skills/skills/langbot-space-ops/SKILL.md new file mode 100644 index 0000000..196205a --- /dev/null +++ b/skills/skills/langbot-space-ops/SKILL.md @@ -0,0 +1,79 @@ +--- +name: langbot-space-ops +description: Browse and search the LangBot Space marketplaces (plugins, MCP servers, skills) through the Space MCP server. Use when an AI agent needs to discover LangBot extensions on space.langbot.app over MCP. Covers the /mcp endpoint, Personal Access Token (PAT) auth, the tool surface, and client configuration. Triggers on "langbot space mcp", "search langbot plugins", "langbot marketplace mcp", "space.langbot.app mcp". +--- + +# LangBot Space MCP Operations + +LangBot Space (space.langbot.app) exposes an **MCP server** so user-facing AI +agents can browse and search the marketplaces (plugins, MCP servers, skills). + +## Endpoint + +``` +https://space.langbot.app/mcp +``` + +Transport: **streamable HTTP** (stateless, JSON responses). For a self-hosted +Space instance: `http://:8383/mcp`. + +## Authentication + +Reuses the existing **Personal Access Token (PAT)** — the same token the `lbp` +CLI uses. Create one in your Space account (Profile → Personal Access Tokens), +then send it as a Bearer token: + +``` +Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`. + +## Client configuration + +```json +{ + "mcpServers": { + "langbot-space": { + "url": "https://space.langbot.app/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +## Tool surface + +| Tool | Purpose | +| --- | --- | +| `list_plugins` / `search_plugins` / `get_plugin` | Plugin marketplace | +| `list_mcp_servers` / `search_mcp_servers` / `get_mcp_server` | MCP-server marketplace | +| `list_skills` / `search_skills` / `get_skill` | Skill marketplace | + +`list_*` and `search_*` are paged (`page`, `page_size`). `get_*` takes +`author` + `name`. The tool surface mirrors the REST endpoints under +`/api/v1/marketplace/*` and is read/browse only. + +## How to use + +1. Create a PAT in your Space account settings. +2. Point your MCP client at `https://space.langbot.app/mcp` with the Bearer PAT. +3. Use `search_plugins` / `search_mcp_servers` / `search_skills` to find items, + then `get_*` for details (e.g. to obtain author/name for installation in + LangBot itself). + +## Implementation & maintenance (for Space developers) + +- Server: `internal/controller/mcp/server.go` (official Go MCP SDK + `github.com/modelcontextprotocol/go-sdk`). Tools call the service layer + (`PluginService`, `MCPService`, `SkillService`) directly. +- Mount: `internal/controller/api.go` at `/mcp` and `/mcp/*any`. +- Auth: PAT via `AccountService.ValidatePersonalAccessToken`. +- Docs: `docs/MCP_SERVER.md`. + +> When you add, remove, or change a marketplace API endpoint that should be +> agent-accessible, update the corresponding MCP tool **and** this skill. The +> MCP tool surface and the API must stay aligned (see `AGENTS.md`). + +## Pitfalls + +- The PAT prefix is `lbpat_` (Space), distinct from LangBot's `lbk_` API keys. +- This server is read/browse only; it does not publish or modify marketplace + items. Use the web UI or REST API (with appropriate auth) for that. diff --git a/skills/skills/langbot-testing/SKILL.md b/skills/skills/langbot-testing/SKILL.md new file mode 100644 index 0000000..748ae9b --- /dev/null +++ b/skills/skills/langbot-testing/SKILL.md @@ -0,0 +1,44 @@ +--- +name: langbot-testing +description: Test LangBot WebUI and core product flows with an automated browser and backend logs. Use when validating the configured LangBot frontend, pipeline Debug Chat, model provider setup and test buttons, bot and knowledge-base UI flows, or troubleshooting failed LangBot end-to-end tests. +--- + +# LangBot Testing + +Use this skill when an agent needs to verify LangBot behavior through the WebUI instead of only reading code. + +## Routing + +- **General WebUI testing**: read `references/web-ui-testing.md`. +- **Pipeline Debug Chat**: read `references/pipeline-debug-chat.md`. +- **Dify AgentRunner**: read `references/dify-agent-runner.md`. +- **Model provider setup or test button**: read `references/model-provider-testing.md`. +- **Plugin install/runtime/tool/page smoke**: read `references/plugin-e2e-smoke.md`. +- **Local Agent Runner**: read `references/local-agent-runner.md`. +- **Local Agent Runner path coverage**: read `references/local-agent-runner-coverage.md`. +- **Diff-aware AgentRunner QA after code changes**: read `references/agent-runner-qa-workflow.md`. +- **Agent Runner release gate**: read `references/agent-runner-release-gate.md`. +- **Sandbox-backed skill authoring**: read `references/sandbox-skill-authoring.md`. +- **LangRAG knowledge bases**: read `references/langrag-knowledge-base.md`. +- **MCP stdio tool testing**: read `references/mcp-stdio-testing.md`. +- **Performance, reliability, or chaos probes**: read `references/performance-reliability-testing.md`. +- **Drive a live instance over MCP (not raw HTTP)**: use the `langbot-mcp-ops` skill — the instance exposes an MCP server at `http://:5300/mcp` (reuses API keys). Useful for setting up bots/pipelines/models as test fixtures programmatically. +- **Known failures and fixes**: read `references/troubleshooting.md`. +- **Reusable test groups**: run `bin/lbs suite list` and `bin/lbs suite plan ` before manually assembling a case set. + +## Rules + +- Read `../.env` first and use `LANGBOT_FRONTEND_URL` and `LANGBOT_BACKEND_URL` instead of hardcoded ports. +- If a standalone frontend dev server is running, `LANGBOT_FRONTEND_URL` may point to `LANGBOT_DEV_FRONTEND_URL`; otherwise it may point to the backend WebUI. +- Confirm the backend and frontend are actually running before testing. +- Run `bin/lbs fixture check` before fixture-heavy MCP, RAG, multimodal, or plugin smoke tests. +- For runner externalization release checks, run `bin/lbs test run agent-runner-release-preflight` before the full `agent-runner-release-gate` suite so configuration blockers are separated from product failures. +- Read `Manual Readiness` in `bin/lbs test plan `; `manual_check` means the declared preconditions or setup still need operator confirmation for this run. +- Use an authenticated browser profile prepared by `langbot-env-setup`. +- Do not expose API keys, OAuth secrets, tokens, or localStorage token values in output. +- A WebUI test is not complete until the visible UI result is checked against backend logs or network behavior. +- A performance result is not complete without `metrics` evidence and a clear split between LangBot overhead and external provider/tool/network time. +- A chaos or reliability result is not complete until the fault scope, cleanup, and recovery checks are recorded. +- For a suite, use `bin/lbs suite start ` to create the suite evidence root, per-case directories, and `suite-start.json`/`suite-start.md` handoff files; use `bin/lbs test result ` to write final per-case `result.json`, then run `bin/lbs suite report --evidence-dir `. +- Do not mark a case `pass` until `test result --evidence` covers every value in the case's `evidence_required`. +- For runner-specific Debug Chat cases, use the case-specific pipeline env declared by `automation_pipeline_url_env` / `automation_pipeline_name_env`; do not silently reuse a generic `LANGBOT_PIPELINE_URL`. diff --git a/skills/skills/langbot-testing/cases/acp-agent-runner-debug-chat.yaml b/skills/skills/langbot-testing/cases/acp-agent-runner-debug-chat.yaml new file mode 100644 index 0000000..ae10cea --- /dev/null +++ b/skills/skills/langbot-testing/cases/acp-agent-runner-debug-chat.yaml @@ -0,0 +1,79 @@ +id: acp-agent-runner-debug-chat +title: "ACP AgentRunner can answer through Debug Chat using real remote Claude" +mode: agent-browser +area: pipeline +type: regression +priority: p2 +risk: high +ci_eligible: false +tags: + - agent-runner + - acp + - claude + - external-runner + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +env_any: + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL|LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME + - LANGBOT_E2E_PROMPT + - LANGBOT_E2E_EXPECTED_TEXT + - LANGBOT_E2E_EXPECTED_RUNNER_ID + - LANGBOT_E2E_RESPONSE_TIMEOUT_MS +automation_pipeline_url_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot/acp-agent-runner/default" +automation_prompt: "Use the injected LangBot MCP server tool langbot_get_current_event once. If the MCP call succeeds, reply with exactly ACP_AGENT_RUNNER_E2E_OK." +automation_expected_text: "ACP_AGENT_RUNNER_E2E_OK" +automation_response_timeout_ms: "300000" +setup_automation: + - "node:scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +preconditions: + - "The remote machine has a working Claude Code login and can run npx -y @agentclientprotocol/claude-agent-acp." + - "LangBot can non-interactively SSH to the remote machine; the runner opens the MCP reverse tunnel automatically." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Open the ACP AgentRunner QA pipeline." + - "Confirm the pipeline AI runner is plugin:langbot/acp-agent-runner/default." + - "Open Debug Chat." + - "Ask the real remote Claude ACP agent to call langbot_get_current_event and return ACP_AGENT_RUNNER_E2E_OK exactly." +checks: + - "UI: Debug Chat shows the user prompt." + - "UI: Debug Chat shows a Bot response containing ACP_AGENT_RUNNER_E2E_OK." + - "Logs: Backend logs include Processing request from person_websocket and Streaming completed for this run." + - "Logs: No acp runner request error appears for this run." + - "Console: No unexpected frontend errors appear during Debug Chat." +evidence_required: + - ui + - console + - backend_log +diagnostics: + - "Use scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env to create/update the pipeline." + - "For remote Claude on 101, verify ssh yhh@101.34.71.12 can run without password prompts; no separate ssh -R process is required." +success_patterns: + - "ACP_AGENT_RUNNER_E2E_OK" + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "acp.command_not_found" + - "acp.process_exited" + - "Agent runner plugin:langbot/acp-agent-runner/default execution failed" +troubleshooting: + - backend-not-listening + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/agent-runner-async-db-readiness.yaml b/skills/skills/langbot-testing/cases/agent-runner-async-db-readiness.yaml new file mode 100644 index 0000000..3324a1c --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-async-db-readiness.yaml @@ -0,0 +1,34 @@ +id: agent-runner-async-db-readiness +title: "AgentRunner async DB readiness probe" +mode: probe +area: release +type: smoke +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - async-db + - aiosqlite +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-async-db-readiness --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation checks whether a direct aiosqlite in-memory connection can create a table within the readiness timeout." +checks: + - "automation-result.json status is pass or env_issue." + - "pass means async SQLite tests are worth running." + - "env_issue means async SQLite-dependent Host pytest probes should be classified as environment-limited until fixed." +evidence_required: + - filesystem +diagnostics: + - "If this probe returns env_issue, run agent-runner-ledger-invariants for fast ledger coverage and skip async ledger pytest as a release blocker in this environment." +success_patterns: + - "AIOSQLITE_READY" +failure_patterns: + - "aiosqlite readiness timed out" +troubleshooting: + - aiosqlite-connect-hangs diff --git a/skills/skills/langbot-testing/cases/agent-runner-behavior-matrix.yaml b/skills/skills/langbot-testing/cases/agent-runner-behavior-matrix.yaml new file mode 100644 index 0000000..944bca6 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-behavior-matrix.yaml @@ -0,0 +1,34 @@ +id: agent-runner-behavior-matrix +title: "AgentRunner deterministic behavior matrix probe" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - deterministic-runner + - protocol +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation reads fixtures/agent-runner/qa-runner-behaviors.json and validates each result sequence through the Host AgentResultNormalizer." +checks: + - "automation-result.json status is pass." + - "probe-stdout.log contains QA_RUNNER_BEHAVIOR_MATRIX_OK." + - "The matrix covers ok, stream_ok, empty_output, malformed_result, and controlled_failure." +evidence_required: + - filesystem +diagnostics: + - "fail means the deterministic behavior fixture and Host result normalization disagree." +success_patterns: + - "QA_RUNNER_BEHAVIOR_MATRIX_OK" +failure_patterns: + - "AssertionError" + - "RunnerProtocolError" + - "behavior matrix exited" diff --git a/skills/skills/langbot-testing/cases/agent-runner-fixture-contract.yaml b/skills/skills/langbot-testing/cases/agent-runner-fixture-contract.yaml new file mode 100644 index 0000000..d8f0e04 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-fixture-contract.yaml @@ -0,0 +1,35 @@ +id: agent-runner-fixture-contract +title: "QA AgentRunner fixture contract probe" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - fixture + - deterministic-runner +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-fixture-contract.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-fixture-contract --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation imports the QA AgentRunner fixture source and executes normal, streaming, and controlled-failure paths with SDK entities." +checks: + - "automation-result.json status is pass." + - "probe-stdout.log contains QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK." + - "Normal input returns QA_AGENT_RUNNER_OK:." + - "Streaming input emits message.delta chunks and completes." + - "Failure input returns QA_AGENT_RUNNER_CONTROLLED_FAILURE." +evidence_required: + - filesystem +diagnostics: + - "This validates the deterministic fixture source contract. It does not prove the plugin package is installed in a live LangBot instance." +success_patterns: + - "QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK" +failure_patterns: + - "AssertionError" + - "fixture contract exited" diff --git a/skills/skills/langbot-testing/cases/agent-runner-ledger-concurrency.yaml b/skills/skills/langbot-testing/cases/agent-runner-ledger-concurrency.yaml new file mode 100644 index 0000000..3b57c54 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-ledger-concurrency.yaml @@ -0,0 +1,41 @@ +id: agent-runner-ledger-concurrency +title: "AgentRunner run ledger concurrency and auth pytest probe" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - pytest + - run-ledger + - concurrency +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs +preconditions: + - "This Host pytest probe can be slow in the current multi-repo dev environment; keep it in the release gate, but do not treat a timeout as a browser E2E failure without checking pytest logs." +steps: + - "Run `rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run` first; remove `--dry-run` only after `agent-runner-async-db-readiness` is pass." + - "Automation resolves LANGBOT_REPO, defaulting to ../LangBot when the env var is unset." + - "Automation runs selected high-value tests from test_run_ledger_store.py and test_run_ledger_api_auth.py." +checks: + - "automation-result.json status is pass." + - "pytest exit status is 0 for selected run ledger claim, lease, status, token, ownership, and active-claim tests." + - "pytest-stdout.log and pytest-stderr.log are written under LBS_EVIDENCE_DIR." +evidence_required: + - filesystem +diagnostics: + - "env_issue means LANGBOT_REPO/default ../LangBot did not resolve, rtk/uv was unavailable, or the expected test files are missing." + - "fail means one of the selected LangBot run ledger pytest targets failed or timed out." +success_patterns: + - "pytest passed" +failure_patterns: + - "pytest exited with status" + - "pytest timed out" + - "Failed to start pytest command" +troubleshooting: + - aiosqlite-connect-hangs diff --git a/skills/skills/langbot-testing/cases/agent-runner-ledger-contention.yaml b/skills/skills/langbot-testing/cases/agent-runner-ledger-contention.yaml new file mode 100644 index 0000000..e90abd8 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-ledger-contention.yaml @@ -0,0 +1,34 @@ +id: agent-runner-ledger-contention +title: "AgentRunner ledger SQLite contention probe" +mode: probe +area: release +type: regression +priority: p1 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - stress + - ledger + - concurrency +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-ledger-contention.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-ledger-contention --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation creates 120 queued runs in a file-backed SQLite database and uses eight worker threads to claim runs under write contention." +checks: + - "automation-result.json status is pass." + - "probe-stdout.log contains LEDGER_CONTENTION_OK." + - "Every run is claimed once, reaches completed status, and has dispatch_attempts = 1." +evidence_required: + - filesystem +diagnostics: + - "This probe catches obvious exactly-once claim regressions under local SQLite contention; it does not replace async Host pytest or PostgreSQL concurrency checks." +success_patterns: + - "LEDGER_CONTENTION_OK" +failure_patterns: + - "AssertionError" + - "ledger contention exited" diff --git a/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml b/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml new file mode 100644 index 0000000..578cd8a --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml @@ -0,0 +1,35 @@ +id: agent-runner-ledger-invariants +title: "AgentRunner ledger schema and status invariants probe" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - ledger + - invariant +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-ledger-invariants --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation resolves LANGBOT_REPO, defaulting to ../LangBot, and imports the sibling SDK from LANGBOT_PLUGIN_SDK_REPO or ../langbot-plugin-sdk/src." + - "Automation checks run status sets, terminal status validation, ledger table/index DDL, and a minimal synchronous insert/read path." +checks: + - "automation-result.json status is pass." + - "probe-stdout.log contains LEDGER_INVARIANTS_OK." + - "The probe does not require aiosqlite or browser UI." +evidence_required: + - filesystem +diagnostics: + - "env_issue means LANGBOT_REPO/default ../LangBot did not resolve or Python dependencies are unavailable." + - "fail means a ledger schema/status invariant changed and the release gate needs review." +success_patterns: + - "LEDGER_INVARIANTS_OK" +failure_patterns: + - "AssertionError" + - "ledger invariant probe exited" diff --git a/skills/skills/langbot-testing/cases/agent-runner-ledger-stress.yaml b/skills/skills/langbot-testing/cases/agent-runner-ledger-stress.yaml new file mode 100644 index 0000000..acc5279 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-ledger-stress.yaml @@ -0,0 +1,33 @@ +id: agent-runner-ledger-stress +title: "AgentRunner ledger lightweight stress probe" +mode: probe +area: release +type: regression +priority: p1 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - stress + - ledger +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-ledger-stress.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-ledger-stress --dry-run` first; remove `--dry-run` after checking the planned evidence directory." + - "Automation creates 100 queued runs in synchronous SQLite and simulates five runtimes claiming them in priority order." +checks: + - "automation-result.json status is pass." + - "probe-stdout.log contains LEDGER_STRESS_OK." + - "Every run is claimed once and reaches a terminal completed status." +evidence_required: + - filesystem +diagnostics: + - "This probe is a fast deterministic stress baseline; it does not replace PostgreSQL/async concurrency tests." +success_patterns: + - "LEDGER_STRESS_OK" +failure_patterns: + - "AssertionError" + - "ledger stress exited" diff --git a/skills/skills/langbot-testing/cases/agent-runner-live-install.yaml b/skills/skills/langbot-testing/cases/agent-runner-live-install.yaml new file mode 100644 index 0000000..f1bcaaf --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-live-install.yaml @@ -0,0 +1,46 @@ +id: agent-runner-live-install +title: "QA AgentRunner package installs and registers in LangBot" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: false +tags: + - agent-runner + - plugin + - local-install + - fixture +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_REPO + - LANGBOT_E2E_LOGIN_USER +automation: scripts/e2e/install-qa-plugin-smoke.mjs +automation_plugin_package: "skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg" +automation_expected_plugin_id: "qa/agent-runner" +automation_expected_tool: "" +automation_expected_runner_id: "plugin:qa/agent-runner/default" +steps: + - "Run `rtk bin/lbs test run agent-runner-live-install --dry-run` first; remove `--dry-run` only after readiness points at a local test LangBot instance." + - "Automation authenticates the local test user, uploads the QA AgentRunner .lbpkg package, waits for the install task, and reads pipeline metadata." +checks: + - "automation-result.json status is pass." + - "/api/v1/plugins lists qa/agent-runner after install." + - "/api/v1/pipelines/_/metadata lists plugin:qa/agent-runner/default as an available runner." +evidence_required: + - api_diagnostic + - filesystem +diagnostics: + - "This proves the deterministic package installs and registers a runner. It does not prove Debug Chat execution; use a later browser case for that." + - "If installation fails during dependencies, inspect plugin runtime logs and plugin-dependency-install-offline." +success_patterns: + - "qa/agent-runner is installed." +failure_patterns: + - "Plugin install task did not complete successfully" + - "plugin:qa/agent-runner/default is not listed" +troubleshooting: + - plugin-runtime-timeout + - plugin-dependency-install-offline diff --git a/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml b/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml new file mode 100644 index 0000000..1d86b5e --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml @@ -0,0 +1,70 @@ +id: agent-runner-qa-debug-chat +title: "QA AgentRunner returns deterministic output through Debug Chat" +mode: agent-browser +area: pipeline +type: regression +priority: p0 +risk: high +ci_eligible: false +tags: + - agent-runner + - pipeline + - debug-chat + - fixture +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME +automation_expected_runner_id: "plugin:qa/agent-runner/default" +automation_prompt: "hello-live" +automation_expected_text: "QA_AGENT_RUNNER_OK:hello-live" +automation_response_timeout_ms: "120000" +automation_reset_debug_chat: "1" +setup_automation: + - "case:agent-runner-live-install" + - "node:scripts/e2e/ensure-qa-agent-runner-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL + - LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Open the pipeline from LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL or LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME." + - "Confirm the pipeline AI runner is plugin:qa/agent-runner/default." + - "Open Debug Chat." + - "Send: hello-live." +checks: + - "UI: The user message appears in Debug Chat." + - "UI: A Bot message appears and contains QA_AGENT_RUNNER_OK:hello-live." + - "API diagnostic: pipeline config uses plugin:qa/agent-runner/default." + - "Console: No unexpected frontend runtime errors appear during the send/receive path." +evidence_required: + - ui + - screenshot + - console + - network + - api_diagnostic +diagnostics: + - "This is the deterministic live execution proof that sits after fixture contract and live install." + - "If the runner id mismatch is reported, rerun ensure-qa-agent-runner-pipeline.mjs --write-env." +success_patterns: + - "QA_AGENT_RUNNER_OK:hello-live" +failure_patterns: + - "plugin:qa/agent-runner/default execution failed" + - "Action invoke_llm_stream call timed out" + - "Agent runner temporarily unavailable" +troubleshooting: + - plugin-runtime-timeout diff --git a/skills/skills/langbot-testing/cases/agent-runner-release-preflight.yaml b/skills/skills/langbot-testing/cases/agent-runner-release-preflight.yaml new file mode 100644 index 0000000..b700005 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-release-preflight.yaml @@ -0,0 +1,74 @@ +id: agent-runner-release-preflight +title: "Agent runner release gate preflight validates environment readiness" +mode: agent-browser +area: release +type: smoke +priority: p0 +risk: high +ci_eligible: false +tags: + - agent-runner + - release-gate + - preflight + - environment +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +env_any: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL|LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +automation: scripts/e2e/agent-runner-release-preflight.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +automation_env_any: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL|LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME +preconditions: + - "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent release pipeline." + - "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL or LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME points to the ACP AgentRunner release pipeline." + - "The active browser profile is authenticated for the same LangBot backend." + - "By default the preflight performs a cheap model test for the local-agent primary model; set LANGBOT_PREFLIGHT_TEST_MODELS=0 only when deliberately classifying model credentials outside this run." +steps: + - "Open LANGBOT_FRONTEND_URL with the configured browser profile." + - "Use the browser token to call LangBot backend readiness APIs without printing token values." + - "Check plugin runtime status, Box status, required runner plugins, qa-plugin-smoke, and qa_plugin_echo." + - "Resolve the local-agent and ACP AgentRunner QA pipelines from their case-specific env vars." + - "Assert each pipeline uses the expected runner id." + - "Assert the external runner pipeline uses the expected runner id." + - "Assert the local-agent primary model advertises func_call and vision for the full release gate." + - "Run the local-agent primary model test endpoint unless LANGBOT_PREFLIGHT_TEST_MODELS=0." +checks: + - "API diagnostic: api-diagnostic.json has no blockers and no env_issues." + - "API diagnostic: required pipelines resolve to plugin:langbot/local-agent/default and plugin:langbot/acp-agent-runner/default." + - "API diagnostic: qa_plugin_echo is exposed by /api/v1/tools." + - "API diagnostic: local-agent model check catches invalid credentials or missing func_call/vision before release E2E starts." + - "Secret safety: token values, api keys, and provider secrets are not printed." +evidence_required: + - ui + - screenshot + - console + - network + - api_diagnostic +diagnostics: + - "blocked means the test instance is not configured for the full release gate: missing pipeline, wrong runner id, or missing plugin." + - "env_issue means the runtime or upstream dependency is not usable: backend unavailable, plugin runtime down, Box down, or the local-agent model cannot pass a model test." + - "If qa_mcp_echo is absent here, continue to mcp-stdio-register before mcp-stdio-tool-call; qa_mcp_echo is not required before registration." + - "If the model check fails with invalid api key, switch the local-agent release pipeline to a known-good func_call model before diagnosing runner code." +success_patterns: + - "Release gate preflight passed" +failure_patterns: + - "Preflight blocked" + - "Preflight environment issue" + - "invalid api key" + - "runner.llm_error" +troubleshooting: + - backend-not-listening + - plugin-runtime-timeout + - local-agent-model-route-unavailable + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml b/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml new file mode 100644 index 0000000..d7aeca3 --- /dev/null +++ b/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml @@ -0,0 +1,38 @@ +id: agent-runner-runtime-chaos +title: "AgentRunner SDK runtime chaos pytest probe" +mode: probe +area: release +type: regression +priority: p0 +risk: high +ci_eligible: true +tags: + - agent-runner + - probe + - pytest + - runtime + - sdk +skills: + - langbot-testing +env: +automation: skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs +steps: + - "Run `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run` first; remove `--dry-run` after checking the SDK repo target and evidence directory." + - "Automation resolves LANGBOT_PLUGIN_SDK_REPO, defaulting to ../langbot-plugin-sdk when the env var is unset." + - "Automation runs the existing SDK pytest files tests/runtime/plugin/test_mgr_agent_runner.py and tests/runtime/test_pull_api_handlers.py." +checks: + - "automation-result.json status is pass." + - "pytest exit status is 0 for the existing AgentRunner runtime and pull API handler tests." + - "pytest-stdout.log and pytest-stderr.log are written under LBS_EVIDENCE_DIR." +evidence_required: + - filesystem +diagnostics: + - "This probe does not open the WebUI; it runs SDK pytest targets directly." + - "env_issue means LANGBOT_PLUGIN_SDK_REPO/default ../langbot-plugin-sdk did not resolve, rtk/uv was unavailable, or the expected test files are missing." + - "fail means the existing SDK runtime pytest target failed or timed out." +success_patterns: + - "pytest passed" +failure_patterns: + - "pytest exited with status" + - "pytest timed out" + - "Failed to start pytest command" diff --git a/skills/skills/langbot-testing/cases/dify-agent-debug-chat.yaml b/skills/skills/langbot-testing/cases/dify-agent-debug-chat.yaml new file mode 100644 index 0000000..42b0f62 --- /dev/null +++ b/skills/skills/langbot-testing/cases/dify-agent-debug-chat.yaml @@ -0,0 +1,51 @@ +id: dify-agent-debug-chat +title: "Dify AgentRunner returns a response through Pipeline Debug Chat" +mode: agent-browser +area: pipeline +type: provider +priority: p2 +risk: medium +ci_eligible: false +tags: + - dify + - agent-runner + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +preconditions: + - "A Dify app Service API key is available from the active secret source and must not be printed in reports." + - "The target pipeline is safe to modify for Dify runner configuration." +steps: + - "Ensure a Dify app Service API key is available from the active secret source." + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target pipeline." + - "Open Configuration > AI." + - "Select runner Dify." + - "Set Base URL, App Type, API Key, Base Prompt, and Timeout according to references/dify-agent-runner.md." + - "Save the pipeline." + - "Open Debug Chat." + - "Send a prompt asking the bot to reply exactly with a unique sentinel, for example LANGBOT_DIFY_OK_." +checks: + - "UI: The runner selector and runner config identify Dify, not a generic Default label." + - "UI: Debug Chat shows a Bot message containing the sentinel." + - "History/log validation: The sentinel is present in an assistant/bot message, not only in the echoed User message." + - "Logs: Backend logs show Dify /chat-messages returned HTTP 200 and Conversation(0) Streaming completed." + - "Console: No unexpected frontend errors appear during runner configuration or Debug Chat." + - "Secret safety: No Dify API key, JWT, or browser token is printed in reports." +evidence_required: + - ui + - console + - backend_log +diagnostics: + - "Use direct Dify streaming API only to distinguish invalid Dify credentials from LangBot runner failures." + - "If direct Dify blocking mode fails for an Agent Chat app, retry streaming before treating credentials as invalid." + - "Use GET /api/v1/pipelines/{uuid} only to confirm saved runner_config." +troubleshooting: + - agent-runner-actor-context-fields + - ambiguous-runner-default-label + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-cross-pipeline-isolation.yaml b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-cross-pipeline-isolation.yaml new file mode 100644 index 0000000..9e8e09a --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-cross-pipeline-isolation.yaml @@ -0,0 +1,84 @@ +id: langbot-fake-provider-debug-chat-cross-pipeline-isolation +title: "LangBot Debug Chat fake-provider cross-pipeline isolation probe" +mode: probe +area: reliability +type: reliability +priority: p1 +risk: high +ci_eligible: false +tags: + - reliability + - debug-chat + - websocket + - fake-provider + - isolation + - concurrency + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_FRONTEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: skills/langbot-testing/probes/langbot-debug-chat-cross-pipeline-isolation.mjs +automation_env: + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_FAKE_PROVIDER_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_A_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME + - LANGBOT_FAKE_PROVIDER_PIPELINE_B_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME +automation_debug_chat_load_requests: "6" +automation_debug_chat_load_concurrency: "4" +automation_debug_chat_load_timeout_ms: "30000" +automation_debug_chat_load_response_p95_ms: "5000" +automation_debug_chat_load_max_error_rate: "0" +automation_debug_chat_load_prompt_template: '请只回复 "{expected}",不要解释,不要添加其他字符。' +automation_debug_chat_load_stream: "true" +automation_debug_chat_load_reset: "true" +metrics_thresholds_json: '{"cross_pipeline_leak_count":{"max":0},"response_p95_ms":{"max":5000},"error_rate":{"max":0}}' +load_profile_json: '{"requests_per_pipeline":6,"pipelines":2,"concurrency":4,"path":"Pipeline Debug Chat WebSocket","provider":"controlled fake OpenAI-compatible provider","metric":"cross-pipeline response isolation and send-to-final-assistant-response"}' +setup_automation: + - "node:scripts/e2e/ensure-fake-provider-cross-pipelines.mjs --write-env" +setup_provides_env: + - LANGBOT_FAKE_PROVIDER_URL + - LANGBOT_FAKE_PROVIDER_BASE_URL + - LANGBOT_FAKE_PROVIDER_PID + - LANGBOT_FAKE_PROVIDER_PIPELINE_A_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME + - LANGBOT_FAKE_PROVIDER_PIPELINE_B_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME +steps: + - "Start or reuse the local fake OpenAI-compatible provider." + - "Create or update two local-agent pipelines that both point at the controlled fake provider." + - "Reset both Debug Chat sessions and the fake-provider request log." + - "Open concurrent WebSocket Debug Chat connections to both pipelines and send unique pipeline-scoped response tokens." +checks: + - "automation-result.json status is pass only when every request receives its own expected token and cross_pipeline_leak_count is zero." + - "metrics_summary includes by_pipeline status counts, fake-provider request count, and LangBot/provider timing estimates." + - "samples.json contains per-request pipeline labels so any leak can be attributed to the receiving pipeline." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe targets Debug Chat isolation under concurrent traffic from two pipelines." + - "It is designed to expose regressions where global pipeline state causes one pipeline's assistant response to be delivered to another pipeline's Debug Chat session." + - "Same-pipeline foreign responses are tolerated because Debug Chat intentionally broadcasts within the same pipeline/session; cross-pipeline tokens are never tolerated." + - "Known product bug: current releases may fail this probe because Debug Chat replies can read singleton WebSocket proxy pipeline state after another pipeline overwrites it. See https://github.com/langbot-app/LangBot/issues/2286." +expected_failures: + - "https://github.com/langbot-app/LangBot/issues/2286" +success_patterns: + - "Debug Chat cross-pipeline isolation probe passed" +failure_patterns: + - "cross_pipeline_leak" + - "Timed out after" + - "WebSocket connection error" + - "Final assistant response did not include" +troubleshooting: + - backend-not-listening + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable diff --git a/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-fault-recovery.yaml b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-fault-recovery.yaml new file mode 100644 index 0000000..7dfa45c --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-fault-recovery.yaml @@ -0,0 +1,95 @@ +id: langbot-fake-provider-debug-chat-fault-recovery +title: "LangBot Debug Chat fake-provider fault recovery probe" +mode: probe +area: reliability +type: chaos +priority: p1 +risk: high +ci_eligible: false +tags: + - reliability + - chaos + - debug-chat + - websocket + - fake-provider + - fault-injection + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_FRONTEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs +automation_env: + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_FAKE_PROVIDER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_debug_chat_load_requests: "6" +automation_debug_chat_load_concurrency: "1" +automation_debug_chat_load_timeout_ms: "15000" +automation_debug_chat_load_response_p95_ms: "5000" +automation_debug_chat_load_max_error_rate: "0" +automation_debug_chat_load_min_ok_count: "6" +automation_debug_chat_load_min_provider_fault_count: "2" +automation_debug_chat_load_expected_prefix: "FAULTQA" +automation_debug_chat_load_prompt_template: '请只回复 "{expected}",不要解释,不要添加其他字符。' +automation_debug_chat_load_stream: "true" +automation_debug_chat_load_reset: "true" +automation_debug_chat_load_fail_on_final_mismatch: "true" +automation_fake_provider_first_token_delay_ms: "25" +automation_fake_provider_chunk_delay_ms: "10" +automation_fake_provider_chunk_count: "0" +automation_fake_provider_fail_first_n: "2" +automation_fake_provider_fail_every_n: "0" +automation_fake_provider_fault_status: "503" +metrics_thresholds_json: '{"response_p95_ms":{"max":5000},"error_rate":{"max":0},"ok_count_min":{"min":6},"fake_provider_fault_count_min":{"min":2}}' +fault_model_json: '{"provider_fault":"HTTP 503 for first 2 fake-provider chat completions after reset","expected_behavior":"LangBot retries or otherwise recovers from bounded provider failures so every Debug Chat request receives its expected response without backend crash."}' +load_profile_json: '{"requests":6,"concurrency":1,"path":"Pipeline Debug Chat WebSocket","provider":"controlled fake OpenAI-compatible provider","classification":"fault-recovery-not-throughput-benchmark"}' +setup_automation: + - "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_FAKE_PROVIDER_URL + - LANGBOT_FAKE_PROVIDER_BASE_URL + - LANGBOT_FAKE_PROVIDER_PID + - LANGBOT_FAKE_PROVIDER_PROVIDER_UUID + - LANGBOT_FAKE_PROVIDER_MODEL_UUID + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +steps: + - "Configure the local fake provider to return HTTP 503 for the first two chat completions after reset." + - "Create or update the LangBot provider, model, and local-agent pipeline that points at the fake provider." + - "Reset the target Debug Chat session and fake-provider request counter." + - "Send a sequential Debug Chat batch and verify later requests recover after the injected provider faults." +checks: + - "automation-result.json status is pass when the fake provider records at least two injected faults, every Debug Chat request succeeds, and total user-visible error rate stays at zero." + - "metrics_summary includes fake_provider_fault_count and status_counts for the same run window." + - "backend logs show request handling for the same run window without unexpected Traceback or task-leak findings." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This is a fault-recovery probe, not a throughput benchmark." + - "Provider faults may be retried inside the provider/requester path; judge this case by fake_provider_fault_count plus user-visible success/error metrics." + - "The profile uses concurrency 1 because Debug Chat broadcasts assistant responses to every connection in a session, and failed responses do not carry the unique success token needed for concurrent attribution." +success_patterns: + - "Debug Chat WebSocket concurrency probe passed" + - "Streaming completed" +failure_patterns: + - "fake_provider_fault" + - "HTTP 503" + - "Timed out after" + - "All models failed during streaming setup" +expected_failures: + - "fake_provider_fault" + - "HTTP 503" +troubleshooting: + - backend-not-listening + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable diff --git a/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-load.yaml b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-load.yaml new file mode 100644 index 0000000..8a71c35 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-load.yaml @@ -0,0 +1,81 @@ +id: langbot-fake-provider-debug-chat-load +title: "LangBot Debug Chat controlled fake-provider load probe" +mode: probe +area: performance +type: performance +priority: p1 +risk: medium +ci_eligible: false +tags: + - performance + - debug-chat + - websocket + - fake-provider + - load + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_FRONTEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs +automation_env: + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_FAKE_PROVIDER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_debug_chat_load_requests: "12" +automation_debug_chat_load_concurrency: "4" +automation_debug_chat_load_timeout_ms: "30000" +automation_debug_chat_load_response_p95_ms: "5000" +automation_debug_chat_load_first_response_p95_ms: "3000" +automation_debug_chat_load_max_error_rate: "0" +automation_debug_chat_load_expected_prefix: "FAKEQA" +automation_debug_chat_load_prompt_template: '请只回复 "{expected}",不要解释,不要添加其他字符。' +automation_debug_chat_load_stream: "true" +automation_debug_chat_load_reset: "true" +metrics_thresholds_json: '{"response_p95_ms":{"max":5000},"first_response_p95_ms":{"max":3000},"error_rate":{"max":0}}' +load_profile_json: '{"requests":12,"concurrency":4,"path":"Pipeline Debug Chat WebSocket","provider":"controlled fake OpenAI-compatible provider","metric":"send-to-final-assistant-response"}' +setup_automation: + - "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_FAKE_PROVIDER_URL + - LANGBOT_FAKE_PROVIDER_BASE_URL + - LANGBOT_FAKE_PROVIDER_PID + - LANGBOT_FAKE_PROVIDER_PROVIDER_UUID + - LANGBOT_FAKE_PROVIDER_MODEL_UUID + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +steps: + - "Start or reuse the local fake OpenAI-compatible provider." + - "Create or update the LangBot provider, model, and local-agent pipeline that points at the fake provider." + - "Reset the target Debug Chat session." + - "Open concurrent WebSocket Debug Chat connections and send unique deterministic prompts through the real backend pipeline." +checks: + - "automation-result.json status is pass when every request receives its own expected assistant response." + - "metrics_summary includes request count, concurrency, p50/p95 response latency, first response latency, throughput, and error rate." + - "thresholds_summary shows response_p95_ms, first_response_p95_ms, and error_rate pass." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe removes external model latency from the measurement; it still exercises the live LangBot backend, provider requester, local-agent runner, pipeline, and Debug Chat WebSocket adapter." + - "Use this as the repeatable message-path baseline before comparing against Space or another real provider." +success_patterns: + - "Debug Chat WebSocket concurrency probe passed" + - "Streaming completed" +failure_patterns: + - "WebSocket connection error" + - "Timed out after" + - "Final assistant response did not include" + - "All models failed during streaming setup" +troubleshooting: + - backend-not-listening + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable diff --git a/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-slow-load.yaml b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-slow-load.yaml new file mode 100644 index 0000000..afa7de1 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-fake-provider-debug-chat-slow-load.yaml @@ -0,0 +1,88 @@ +id: langbot-fake-provider-debug-chat-slow-load +title: "LangBot Debug Chat slow fake-provider load probe" +mode: probe +area: performance +type: performance +priority: p1 +risk: medium +ci_eligible: false +tags: + - performance + - debug-chat + - websocket + - fake-provider + - slow-provider + - load + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_FRONTEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs +automation_env: + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_FAKE_PROVIDER_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +automation_debug_chat_load_requests: "8" +automation_debug_chat_load_concurrency: "4" +automation_debug_chat_load_timeout_ms: "45000" +automation_debug_chat_load_response_p95_ms: "10000" +automation_debug_chat_load_first_response_p95_ms: "7000" +automation_debug_chat_load_max_error_rate: "0" +automation_debug_chat_load_expected_prefix: "SLOWQA" +automation_debug_chat_load_prompt_template: '请只回复 "{expected}",不要解释,不要添加其他字符。' +automation_debug_chat_load_stream: "true" +automation_debug_chat_load_reset: "true" +automation_fake_provider_first_token_delay_ms: "1000" +automation_fake_provider_chunk_delay_ms: "250" +automation_fake_provider_chunk_count: "4" +automation_fake_provider_fail_first_n: "0" +automation_fake_provider_fail_every_n: "0" +automation_fake_provider_fault_status: "500" +metrics_thresholds_json: '{"response_p95_ms":{"max":10000},"first_response_p95_ms":{"max":7000},"error_rate":{"max":0}}' +load_profile_json: '{"requests":8,"concurrency":4,"path":"Pipeline Debug Chat WebSocket","provider":"controlled slow fake OpenAI-compatible provider","metric":"send-to-final-assistant-response","provider_profile":{"first_token_delay_ms":1000,"chunk_delay_ms":250,"chunk_count":4}}' +setup_automation: + - "node:scripts/e2e/ensure-fake-provider-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_FAKE_PROVIDER_URL + - LANGBOT_FAKE_PROVIDER_BASE_URL + - LANGBOT_FAKE_PROVIDER_PID + - LANGBOT_FAKE_PROVIDER_PROVIDER_UUID + - LANGBOT_FAKE_PROVIDER_MODEL_UUID + - LANGBOT_FAKE_PROVIDER_PIPELINE_URL + - LANGBOT_FAKE_PROVIDER_PIPELINE_NAME +steps: + - "Configure the local fake provider with deterministic slow streaming latency." + - "Create or update the LangBot provider, model, and local-agent pipeline that points at the fake provider." + - "Reset the target Debug Chat session." + - "Open concurrent WebSocket Debug Chat connections and send unique deterministic prompts through the real backend pipeline." +checks: + - "automation-result.json status is pass when every request receives its own expected assistant response." + - "metrics_summary shows zero errors under the slow-provider profile." + - "thresholds_summary shows response_p95_ms, first_response_p95_ms, and error_rate pass." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe keeps the model deterministic while injecting provider latency, so it catches backend timeout, streaming, and WebSocket backpressure issues without Space variability." + - "Compare with langbot-fake-provider-debug-chat-load to separate fixed LangBot overhead from provider-latency amplification." +success_patterns: + - "Debug Chat WebSocket concurrency probe passed" + - "Streaming completed" +failure_patterns: + - "WebSocket connection error" + - "Timed out after" + - "Final assistant response did not include" + - "All models failed during streaming setup" +troubleshooting: + - backend-not-listening + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable diff --git a/skills/skills/langbot-testing/cases/langbot-fault-taxonomy-contract.yaml b/skills/skills/langbot-testing/cases/langbot-fault-taxonomy-contract.yaml new file mode 100644 index 0000000..2b990f8 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-fault-taxonomy-contract.yaml @@ -0,0 +1,35 @@ +id: langbot-fault-taxonomy-contract +title: "LangBot fault taxonomy and cleanup contract" +mode: probe +area: reliability +type: chaos +priority: p1 +risk: medium +ci_eligible: true +tags: + - reliability + - chaos + - contract + - synthetic +skills: + - langbot-testing +automation: skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs +fault_model_json: '{"kind":"taxonomy-contract","destructive":false,"scenarios":["provider-timeout","plugin-runtime-disconnect","mcp-stdio-server-exit","operator-missing-login","transient-marketplace-timeout"]}' +steps: + - "Run `rtk bin/lbs test run langbot-fault-taxonomy-contract --dry-run` first; remove `--dry-run` after checking the evidence directory." + - "Automation validates that representative fault scenarios declare target, injected fault, expected status, recovery check, and cleanup." + - "Review metrics.json, fault-model.json, and automation-result.json under LBS_EVIDENCE_DIR." +checks: + - "automation-result.json status is pass." + - "Every scenario has an expected status in pass, fail, blocked, env_issue, or flaky." + - "Every scenario declares a cleanup action and recovery check." +evidence_required: + - metrics + - filesystem +diagnostics: + - "This is a non-destructive taxonomy contract probe; it does not inject real runtime faults." + - "Use it as a gate before adding live chaos cases that kill runtimes, route traffic through a proxy, or disrupt a backend dependency." +success_patterns: + - "Fault taxonomy contract declares status" +failure_patterns: + - "missing required scenario fields" diff --git a/skills/skills/langbot-testing/cases/langbot-live-backend-latency.yaml b/skills/skills/langbot-testing/cases/langbot-live-backend-latency.yaml new file mode 100644 index 0000000..1922d06 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-live-backend-latency.yaml @@ -0,0 +1,42 @@ +id: langbot-live-backend-latency +title: "LangBot live backend basic latency probe" +mode: probe +area: performance +type: performance +priority: p1 +risk: medium +ci_eligible: false +tags: + - performance + - live-backend + - latency + - metrics +skills: + - langbot-testing +env: + - LANGBOT_BACKEND_URL +automation: skills/langbot-testing/probes/langbot-live-backend-latency.mjs +metrics_thresholds_json: '{"backend_p95_ms":{"max":1000},"error_rate":{"max":0}}' +load_profile_json: '{"requests":12,"concurrency":2,"endpoints":["/healthz"]}' +steps: + - "Confirm the selected LangBot backend is the intended test target." + - "Run `rtk bin/lbs test run langbot-live-backend-latency --dry-run` first; remove `--dry-run` after checking LANGBOT_BACKEND_URL and evidence directory." + - "Automation sends a small request batch to LANGBOT_BACKEND_URL/healthz and records latency, status counts, and network errors." +checks: + - "automation-result.json status is pass when the backend responds and p95/error-rate thresholds pass." + - "automation-result.json status is env_issue when the backend is not reachable." + - "metrics.json and network.log are written under LBS_EVIDENCE_DIR." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe measures backend health endpoint reachability latency only; it does not cover model/provider, browser, Debug Chat, RAG, or plugin runtime latency." +success_patterns: + - "Live backend latency probe passed" +failure_patterns: + - "Backend did not respond" + - "breached latency or error-rate thresholds" +troubleshooting: + - socks-proxy-without-socksio diff --git a/skills/skills/langbot-testing/cases/langbot-live-backend-log-health.yaml b/skills/skills/langbot-testing/cases/langbot-live-backend-log-health.yaml new file mode 100644 index 0000000..8ff9113 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-live-backend-log-health.yaml @@ -0,0 +1,45 @@ +id: langbot-live-backend-log-health +title: "LangBot live backend log health probe" +mode: probe +area: reliability +type: reliability +priority: p1 +risk: medium +ci_eligible: false +tags: + - reliability + - live-backend + - backend-log + - metrics +skills: + - langbot-testing +env: + - LANGBOT_BACKEND_URL +automation: skills/langbot-testing/probes/langbot-live-backend-log-health.mjs +metrics_thresholds_json: '{"fail_count":{"max":0}}' +load_profile_json: '{"lookback_seconds":300,"log_source":"LANGBOT_BACKEND_LOG or latest LANGBOT_REPO/data/logs/langbot-*.log"}' +steps: + - "Confirm the selected LangBot backend log belongs to the intended test target." + - "Run `rtk bin/lbs test run langbot-live-backend-log-health --dry-run` first; remove `--dry-run` after checking evidence directory and log source." + - "Automation scans the recent backend log window for fail-severity runtime findings such as Traceback, ImportError, ERROR, unclosed sessions, and unawaited coroutines." +checks: + - "automation-result.json status is pass only when fail_count is 0." + - "metrics_summary includes scanned_line_count, fail_count, warning_count, and finding_count." + - "findings.json and scanned-backend.log are written under LBS_EVIDENCE_DIR." +evidence_required: + - metrics + - backend_log + - filesystem +diagnostics: + - "Set LANGBOT_BACKEND_LOG to an explicit log path when the latest log file is not the run target." + - "Set LANGBOT_BACKEND_LOG_SINCE or LANGBOT_BACKEND_LOG_LOOKBACK_SECONDS to control the scan window." + - "This probe measures runtime log health; it does not prove user-facing Debug Chat, plugin, model, or RAG behavior." +success_patterns: + - "Live backend log health passed" +failure_patterns: + - "Traceback" + - "ImportError" + - "ERROR" + - "unclosed" +troubleshooting: + - socks-proxy-without-socksio diff --git a/skills/skills/langbot-testing/cases/langbot-live-control-plane-api.yaml b/skills/skills/langbot-testing/cases/langbot-live-control-plane-api.yaml new file mode 100644 index 0000000..2cd8ee2 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-live-control-plane-api.yaml @@ -0,0 +1,44 @@ +id: langbot-live-control-plane-api +title: "LangBot live control-plane API probe" +mode: probe +area: performance +type: performance +priority: p1 +risk: medium +ci_eligible: false +tags: + - performance + - reliability + - live-backend + - control-plane + - metrics +skills: + - langbot-testing +env: + - LANGBOT_BACKEND_URL +automation: skills/langbot-testing/probes/langbot-live-control-plane-api.mjs +metrics_thresholds_json: '{"error_rate":{"max":0},"response_shape_failures":{"max":0},"healthz_p95_ms":{"max":500},"system_info_p95_ms":{"max":1000}}' +load_profile_json: '{"requests":20,"concurrency":4,"endpoints":["/healthz","/api/v1/system/info"],"auth_required":false}' +steps: + - "Confirm the selected LangBot backend is the intended test target." + - "Run `rtk bin/lbs test run langbot-live-control-plane-api --dry-run` first; remove `--dry-run` after checking LANGBOT_BACKEND_URL and evidence directory." + - "Automation sends a small request batch to /healthz and /api/v1/system/info, then validates status code, JSON shape, and latency budgets." +checks: + - "automation-result.json status is pass when every control-plane request returns HTTP 200, JSON code 0, and required response fields." + - "metrics_summary includes per-endpoint p50/p95 latency, error rate, status counts, and response_shape_failures." + - "thresholds_summary shows error_rate, response_shape_failures, healthz_p95_ms, and system_info_p95_ms all pass." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe measures unauthenticated backend control-plane readiness; it does not cover authenticated UI flows, Debug Chat, model calls, plugins, or RAG." + - "A system_info shape failure usually means the API contract or startup state changed and should be investigated before treating latency as healthy." +success_patterns: + - "Live control-plane API probe passed" +failure_patterns: + - "Backend did not respond" + - "breached shape, latency, or error-rate thresholds" +troubleshooting: + - socks-proxy-without-socksio diff --git a/skills/skills/langbot-testing/cases/langbot-overhead-accounting-contract.yaml b/skills/skills/langbot-testing/cases/langbot-overhead-accounting-contract.yaml new file mode 100644 index 0000000..650dfe7 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-overhead-accounting-contract.yaml @@ -0,0 +1,37 @@ +id: langbot-overhead-accounting-contract +title: "LangBot overhead accounting metrics contract" +mode: probe +area: performance +type: performance +priority: p1 +risk: medium +ci_eligible: true +tags: + - performance + - metrics + - contract + - synthetic +skills: + - langbot-testing +automation: skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs +metrics_thresholds_json: '{"sample_count":{"min":50},"langbot_overhead_p95_ms":{"max":25},"accounting_gap_max_ms":{"max":0.001}}' +load_profile_json: '{"kind":"synthetic-overhead-accounting","samples":80,"external_latency_segments":["provider","external_tool","network"]}' +steps: + - "Run `rtk bin/lbs test run langbot-overhead-accounting-contract --dry-run` first; remove `--dry-run` after checking the evidence directory." + - "Automation generates deterministic message-path latency samples and separates LangBot overhead from provider/tool/network latency." + - "Review metrics.json, thresholds.json, resource-log.json, and automation-result.json under LBS_EVIDENCE_DIR." +checks: + - "automation-result.json status is pass." + - "metrics_summary includes sample_count, langbot_overhead_p95_ms, e2e_latency_p95_ms, external_latency_p95_ms, and accounting_gap_max_ms." + - "thresholds_summary shows sample_count, langbot_overhead_p95_ms, and accounting_gap_max_ms all pass." +evidence_required: + - metrics + - resource_log + - filesystem +diagnostics: + - "This is a synthetic contract probe for the QA harness; it is not live product performance." + - "Use it to verify that reports can carry overhead accounting metrics before running live backend or browser performance probes." +success_patterns: + - "Overhead accounting contract passed" +failure_patterns: + - "breached one or more thresholds" diff --git a/skills/skills/langbot-testing/cases/langbot-space-debug-chat-concurrency-smoke.yaml b/skills/skills/langbot-testing/cases/langbot-space-debug-chat-concurrency-smoke.yaml new file mode 100644 index 0000000..4f9fc77 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langbot-space-debug-chat-concurrency-smoke.yaml @@ -0,0 +1,84 @@ +id: langbot-space-debug-chat-concurrency-smoke +title: "LangBot Debug Chat real Space-provider concurrency smoke" +mode: probe +area: performance +type: performance +priority: p1 +risk: high +ci_eligible: false +tags: + - performance + - debug-chat + - websocket + - space + - live-provider + - smoke + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_FRONTEND_URL + - LANGBOT_E2E_LOGIN_USER +automation: skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs +automation_env: + - LANGBOT_BACKEND_URL + - LANGBOT_E2E_LOGIN_USER + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_debug_chat_load_requests: "3" +automation_debug_chat_load_concurrency: "2" +automation_debug_chat_load_timeout_ms: "120000" +automation_debug_chat_load_response_p95_ms: "120000" +automation_debug_chat_load_max_error_rate: "0" +automation_debug_chat_load_expected_prefix: "SPACEQA" +automation_debug_chat_load_prompt_template: '请只回复 "{expected}",不要解释,不要添加其他字符。' +automation_debug_chat_load_stream: "true" +automation_debug_chat_load_reset: "true" +metrics_thresholds_json: '{"response_p95_ms":{"max":120000},"error_rate":{"max":0}}' +load_profile_json: '{"requests":3,"concurrency":2,"path":"Pipeline Debug Chat WebSocket","provider":"LangBot Space model route","metric":"send-to-final-assistant-response","classification":"smoke-not-benchmark"}' +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_PIPELINE_URL + - LANGBOT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_MODEL_UUID + - LANGBOT_E2E_MODEL_UUID +preconditions: + - "The selected local LangBot instance is safe for a low-volume real Space model smoke run." + - "Treat Space/provider/network failures as environment or dependency findings until fake-provider baseline evidence separates LangBot overhead." +steps: + - "Prepare a local-agent pipeline with a tested Space model and fallback models." + - "Reset the target Debug Chat session." + - "Open a small number of concurrent WebSocket Debug Chat connections and send unique deterministic prompts through the live Space provider path." +checks: + - "automation-result.json status is pass when every request receives its own expected assistant response." + - "metrics_summary includes request count, concurrency, p95 response latency, throughput, and error rate." + - "The report classifies the result as a live-provider smoke, not a stable LangBot overhead benchmark." +evidence_required: + - metrics + - network + - api_diagnostic + - filesystem +diagnostics: + - "This probe measures real user-path latency through Space and includes provider latency, model behavior, and network effects." + - "Compare with langbot-fake-provider-debug-chat-load before attributing slow or failed runs to LangBot itself." +success_patterns: + - "Debug Chat WebSocket concurrency probe passed" + - "Streaming completed" +failure_patterns: + - "invalid api key" + - "WebSocket connection error" + - "Timed out after" + - "Final assistant response did not include" + - "All models failed during streaming setup" +troubleshooting: + - local-agent-model-route-unavailable + - marketplace-network-flaky + - proxy-env-mismatch + - telemetry-proxy-noise diff --git a/skills/skills/langbot-testing/cases/langrag-kb-retrieve.yaml b/skills/skills/langbot-testing/cases/langrag-kb-retrieve.yaml new file mode 100644 index 0000000..d7dbd88 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langrag-kb-retrieve.yaml @@ -0,0 +1,57 @@ +id: langrag-kb-retrieve +title: "LangRAG knowledge base ingests and retrieves a sentinel document" +mode: agent-browser +area: knowledge +type: feature +priority: p1 +risk: medium +ci_eligible: false +tags: + - langrag + - knowledge + - rag +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +automation: scripts/e2e/langrag-kb-retrieve.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +automation_env_any: + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID|LANGBOT_RAG_KB_UUID +automation_expected_text: "azalea-cobalt-7421" +preconditions: + - "LangRAG is installed and initialized in the active LangBot instance." + - "A working embedding model is available, preferably chroma-all-MiniLM-L6-v2 for local repeatability." + - "LANGBOT_LOCAL_AGENT_RAG_KB_UUID points to a LangRAG knowledge base containing azalea-cobalt-7421." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Knowledge." + - "Create a knowledge base with engine LangRAG." + - "Select a working embedding model, preferably local Chroma embedding model chroma-all-MiniLM-L6-v2." + - "Upload skills/langbot-testing/fixtures/rag/sentinel-doc.txt." + - "Wait until the document row status is Completed." + - "Open Retrieve Test and query: What is the local agent runner retrieval sentinel?" +checks: + - "UI: The knowledge base appears in the Knowledge sidebar." + - "UI: The uploaded document status becomes Completed." + - "UI: Retrieve Test shows the uploaded document content." + - "UI: Retrieve Test result contains azalea-cobalt-7421." + - "Console: No unexpected frontend errors appear during creation, upload, or retrieve." +evidence_required: + - ui + - screenshot + - console + - backend_log +diagnostics: + - "If no LangRAG engine is available, check /api/v1/knowledge/engines and install langbot-team/LangRAG." + - "If the embedding selector does not show a local Chroma model, confirm the model exists under embedding_models, not llm_models." +troubleshooting: + - marketplace-network-flaky + - dynamic-form-missing-config-id + - pipeline-form-controlled-warning diff --git a/skills/skills/langbot-testing/cases/langrag-parser-golden-e2e.yaml b/skills/skills/langbot-testing/cases/langrag-parser-golden-e2e.yaml new file mode 100644 index 0000000..c1c7da3 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langrag-parser-golden-e2e.yaml @@ -0,0 +1,69 @@ +id: langrag-parser-golden-e2e +title: "LangRAG and GeneralParsers retrieve a structured golden document" +mode: agent-browser +area: knowledge +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - golden + - e2e + - langrag + - parser + - knowledge +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_REPO + - LANGBOT_WEB_REPO + - LANGBOT_RAG_PLUGIN_REPO + - LANGBOT_PARSER_PLUGIN_REPO +preconditions: + - "LANGBOT_REPO and plugin repo env values point to the worktrees intended for this golden parser/RAG run." + - "The active LangBot environment can build and install local LangRAG and GeneralParsers plugin packages." + - "A working embedding model is available before the Knowledge UI path starts." +steps: + - "Use LANGBOT_REPO as the active LangBot worktree and confirm it is the current master worktree for this run." + - "Start or verify the LangBot backend and frontend from LANGBOT_REPO and LANGBOT_WEB_REPO." + - "Build local plugin zips from LANGBOT_RAG_PLUGIN_REPO and LANGBOT_PARSER_PLUGIN_REPO using LANGBOT_REPO/.venv/bin/lbp build." + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Plugins and install or update the local LangRAG zip and GeneralParsers zip." + - "Wait until both plugins are initialized: langbot-team/LangRAG and langbot-team/GeneralParsers." + - "Navigate to Knowledge." + - "Create a knowledge base with engine LangRAG and a working embedding model, preferably chroma-all-MiniLM-L6-v2." + - "Keep index type Chunk for this golden case." + - "Upload skills/langbot-testing/fixtures/rag/parser-golden.html." + - "When the upload UI asks for a parser, select GeneralParsers for text/html." + - "Wait until the document row status is Completed." + - "Open Retrieve Test and query: What is the parser-rag golden sentinel and which parser/engine pair is documented? Return the exact sentinel and pair." +checks: + - "UI: Plugins shows langbot-team/LangRAG initialized." + - "UI: Plugins shows langbot-team/GeneralParsers initialized." + - "UI: The upload flow selects GeneralParsers for the HTML fixture, or the parser selector clearly defaults to GeneralParsers." + - "UI: The uploaded parser-golden.html document status becomes Completed." + - "UI: Retrieve Test result contains aurora-parser-rag-9137." + - "UI: Retrieve Test result contains GeneralParsers and LangRAG." + - "UI: Retrieve Test result preserves the table as Markdown, including '| Parser field | Golden value |'." + - "Console: No unexpected frontend errors appear during plugin install, KB creation, upload, or retrieve." + - "Logs: Backend/plugin logs show GeneralParsers parsed parser-golden.html and LangRAG used pre-parsed external parser content." +evidence_required: + - ui + - screenshot + - console + - backend_log +diagnostics: + - "If LangRAG is missing, check /api/v1/knowledge/engines for plugin id langbot-team/LangRAG." + - "If GeneralParsers is missing, check /api/v1/knowledge/parsers?mime_type=text/html for plugin id langbot-team/GeneralParsers." + - "If GeneralParsers local install fails on PyMuPDF, confirm the active LangBot master venv can import fitz and retry Upload Local." + - "If the document completes but table pipes are missing, inspect logs for LangRAG fallback to the internal FileParser instead of external parser content." + - "If embedding selection is empty, confirm the test embedding model exists under embedding_models, not llm_models." +troubleshooting: + - plugin-runtime-timeout + - plugin-dependency-install-offline + - marketplace-network-flaky + - dynamic-form-missing-config-id + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/langrag-sentinel-kb-discover.yaml b/skills/skills/langbot-testing/cases/langrag-sentinel-kb-discover.yaml new file mode 100644 index 0000000..1e1c246 --- /dev/null +++ b/skills/skills/langbot-testing/cases/langrag-sentinel-kb-discover.yaml @@ -0,0 +1,38 @@ +id: langrag-sentinel-kb-discover +title: "Existing LangRAG sentinel knowledge base is discoverable" +mode: probe +area: knowledge +type: regression +priority: p1 +risk: medium +ci_eligible: false +tags: + - langrag + - knowledge + - rag + - fixture +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_REPO + - LANGBOT_E2E_LOGIN_USER +automation: scripts/e2e/ensure-langrag-sentinel-kb.mjs +automation_expected_text: "azalea-cobalt-7421" +steps: + - "Run `rtk bin/lbs test run langrag-sentinel-kb-discover --dry-run` first; remove `--dry-run` only after readiness points at a local test LangBot instance." + - "Automation authenticates the local test user, lists knowledge bases, retrieves against each one, and looks for azalea-cobalt-7421." +checks: + - "automation-result.json status is pass when an existing KB retrieves the sentinel." + - "When run with `node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env`, LANGBOT_LOCAL_AGENT_RAG_KB_UUID is written to skills/.env.local." +evidence_required: + - api_diagnostic +diagnostics: + - "This case does not create a knowledge base. It only discovers a KB already prepared by langrag-kb-retrieve or by an equivalent local setup." +success_patterns: + - "Found LangRAG sentinel knowledge base" +failure_patterns: + - "No existing knowledge base retrieved expected sentinel" +troubleshooting: + - marketplace-network-flaky diff --git a/skills/skills/langbot-testing/cases/local-agent-basic-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-basic-debug-chat.yaml new file mode 100644 index 0000000..0b1cc13 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-basic-debug-chat.yaml @@ -0,0 +1,71 @@ +id: local-agent-basic-debug-chat +title: "Local Agent Debug Chat returns a deterministic streaming response" +mode: agent-browser +area: pipeline +type: smoke +priority: p0 +risk: medium +ci_eligible: false +tags: + - local-agent + - pipeline + - streaming +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "请只回复 OK,用于前端调试测试。" +automation_expected_text: "OK" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target local-agent pipeline." + - "Open Configuration > AI." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model that is known to answer Debug Chat in the current environment." + - "Clear Knowledge Bases unless this run intentionally combines RAG with the smoke path." + - "Save the pipeline." + - "Open Debug Chat." + - "Ensure the stream switch is enabled when the UI exposes it." + - "Send: 请只回复 OK,用于前端调试测试。" +checks: + - "UI: The user message appears in Debug Chat." + - "UI: A Bot message appears and contains OK." + - "Console: No unexpected frontend runtime errors appear during the send/receive path." + - "Logs: Backend logs show the debug chat request completed on the streaming path instead of timing out in plugin/runtime calls." +evidence_required: + - ui + - screenshot + - console + - backend_log +diagnostics: + - "Provider errors such as model_not_found or no available channel mean the selected model is unavailable; switch to a known-good model before diagnosing local-agent." + - "Use GET /api/v1/pipelines/{uuid} only to confirm the saved runner and model config." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "Task exception was never retrieved" + - "survey widget blocks debug chat" +troubleshooting: + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml new file mode 100644 index 0000000..7c0d6a3 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml @@ -0,0 +1,87 @@ +id: local-agent-context-compaction-debug-chat +title: "Local Agent compacts long Debug Chat history and preserves older facts" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - pipeline + - context + - compaction +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot/local-agent/default" +automation_runner_config_patch_json: '{"context-window-tokens":225,"context-reserve-tokens":50,"context-keep-recent-tokens":30,"context-summary-tokens":105,"knowledge-bases":[]}' +automation_restore_runner_config: "1" +automation_reset_debug_chat: "1" +automation_debug_chat_session_type: "person" +automation_expected_text: "qa_compaction_sentinel_7391" +automation_response_timeout_ms: "180000" +automation_prompts_json: '[{"prompt":"请记住这个用于 local-agent context compaction 回归测试的暗号:qa_compaction_sentinel_7391。请只回复 MEMORY_SET。","expected_text":"MEMORY_SET","response_timeout_ms":"180000"},{"prompt":"下面这轮只用于制造长历史压力,内容没有业务含义。请忽略填充内容,最后只回复 CONTEXT_PRESSURE_READY。填充片段 A001 context padding for local-agent compaction. A002 context padding for local-agent compaction. A003 context padding for local-agent compaction. A004 context padding for local-agent compaction. A005 context padding for local-agent compaction. A006 context padding for local-agent compaction. A007 context padding for local-agent compaction. A008 context padding for local-agent compaction. A009 context padding for local-agent compaction. A010 context padding for local-agent compaction. A011 context padding for local-agent compaction. A012 context padding for local-agent compaction. A013 context padding for local-agent compaction. A014 context padding for local-agent compaction. A015 context padding for local-agent compaction. A016 context padding for local-agent compaction. A017 context padding for local-agent compaction. A018 context padding for local-agent compaction. A019 context padding for local-agent compaction. A020 context padding for local-agent compaction. A021 context padding for local-agent compaction. A022 context padding for local-agent compaction. A023 context padding for local-agent compaction. A024 context padding for local-agent compaction. A025 context padding for local-agent compaction. A026 context padding for local-agent compaction. A027 context padding for local-agent compaction. A028 context padding for local-agent compaction. A029 context padding for local-agent compaction. A030 context padding for local-agent compaction. A031 context padding for local-agent compaction. A032 context padding for local-agent compaction. A033 context padding for local-agent compaction. A034 context padding for local-agent compaction. A035 context padding for local-agent compaction. A036 context padding for local-agent compaction. A037 context padding for local-agent compaction. A038 context padding for local-agent compaction. A039 context padding for local-agent compaction. A040 context padding for local-agent compaction.","expected_text":"CONTEXT_PRESSURE_READY","response_timeout_ms":"180000"},{"prompt":"刚才第一轮我要求你记住的测试暗号是什么?请只回复暗号本身,不要解释。","expected_text":"qa_compaction_sentinel_7391","response_timeout_ms":"180000"}]' +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The selected model route can follow short deterministic instructions across multiple Debug Chat turns." +steps: + - "Open the target local-agent pipeline through LANGBOT_FRONTEND_URL." + - "Use the authenticated browser token only inside automation to GET and PUT /api/v1/pipelines/{uuid}." + - "Assert the saved runner is plugin:langbot/local-agent/default." + - "Temporarily set context-window-tokens, context-reserve-tokens, context-keep-recent-tokens, and context-summary-tokens to force compaction, and clear knowledge-bases so RAG does not answer the memory question." + - "Reset the person Debug Chat session for the target pipeline." + - "Send the sentinel memory prompt and wait for MEMORY_SET." + - "Send the long padding prompt and wait for CONTEXT_PRESSURE_READY." + - "Ask for the original sentinel and wait for qa_compaction_sentinel_7391." + - "Restore the original runner config." +checks: + - "UI: All three user messages appear in Debug Chat." + - "UI: The final Bot message contains qa_compaction_sentinel_7391." + - "API diagnostic: pipeline-config-diagnostic.json shows patched=true and patch_keys include the four token context compaction fields plus knowledge-bases." + - "API diagnostic: pipeline-config-restore-diagnostic.json shows the original runner config was restored." + - "Logs: Backend completes the multi-turn Debug Chat path without runner timeout or model setup failure." + - "Console: No unexpected frontend runtime errors appear during the run." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "If the final sentinel is missing, inspect whether pipeline-config-diagnostic.json targeted ai.runner_config[runnerId], cleared knowledge-bases, and whether the backend log shows the local-agent runner loading the small context settings." + - "If the model ignores deterministic replies, rerun with a known-good model route before diagnosing ContextAssembler." + - "If restore fails, use pipeline-config-restore-diagnostic.json and GET /api/v1/pipelines/{uuid} to confirm the current saved config before retrying." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Task exception was never retrieved" + - "survey widget blocks debug chat" +troubleshooting: + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat + - debug-chat-history-contaminates-automation diff --git a/skills/skills/langbot-testing/cases/local-agent-effective-prompt-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-effective-prompt-debug-chat.yaml new file mode 100644 index 0000000..d19d4a1 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-effective-prompt-debug-chat.yaml @@ -0,0 +1,67 @@ +id: local-agent-effective-prompt-debug-chat +title: "Local Agent consumes host effective prompt after PromptPreProcessing" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - prompt + - plugin + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "qa-effective-prompt" +automation_expected_text: "PROMPT_PREPROCESS_OK" +automation_response_timeout_ms: "180000" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The target pipeline is safe to modify and can bind the fixture plugin through Extensions." +steps: + - "Install or enable the bundled qa-plugin-smoke fixture plugin." + - "Confirm the fixture plugin is bound to the target pipeline through Extensions." + - "Open the target local-agent pipeline." + - "Open Configuration > AI." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model that is known to follow system prompts in Debug Chat." + - "Save the pipeline." + - "Open Debug Chat." + - "Send: qa-effective-prompt" +checks: + - "UI: A Bot message appears and contains PROMPT_PREPROCESS_OK." + - "Logs: PromptPreProcessing runs for the fixture plugin before the local-agent runner invokes the model." + - "Logs: Backend completes the debug-chat request without plugin/runtime timeout." + - "Console: No unexpected frontend runtime errors appear during configuration or chat." +evidence_required: + - ui + - console + - backend_log +diagnostics: + - "If the bot does not return PROMPT_PREPROCESS_OK, verify the fixture plugin is installed, enabled, and bound to the pipeline before diagnosing ctx.adapter.extra.prompt." + - "If the plugin event runs but the answer ignores the sentinel, inspect whether the runner is using ctx.adapter.extra.prompt instead of static runner config prompt." +troubleshooting: + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-multimodal-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-multimodal-debug-chat.yaml new file mode 100644 index 0000000..298b59d --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-multimodal-debug-chat.yaml @@ -0,0 +1,71 @@ +id: local-agent-multimodal-debug-chat +title: "Local Agent Debug Chat preserves uploaded image input" +mode: agent-browser +area: pipeline +type: regression +priority: p2 +risk: medium +ci_eligible: false +tags: + - local-agent + - multimodal + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "I attached an image. Reply only IMAGE_OK if you received the image." +automation_expected_text: "IMAGE_OK" +automation_image_base64_fixture: "skills/langbot-testing/fixtures/multimodal/red-square.png.base64" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The selected model route accepts image input, or the case is intentionally checking graceful provider rejection." +steps: + - "Prepare a small PNG file for upload. The bundled fixture base64 is at skills/langbot-testing/fixtures/multimodal/red-square.png.base64 if a temporary file is needed." + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target local-agent pipeline." + - "Open Configuration > AI." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model that supports image input in the current environment, or use a known model that at least accepts the uploaded image payload." + - "Save the pipeline." + - "Open Debug Chat." + - "Attach the PNG through the image/file upload control. Prefer the bundled 64x64 red-square fixture; 1x1 images may be rejected by some model providers before runner behavior is exercised." + - "Confirm the user compose area or sent message shows the image attachment." + - "Send: I attached an image. Reply only IMAGE_OK if you received the image." +checks: + - "UI: The sent User message shows an image attachment, not just text." + - "UI: The Bot message contains IMAGE_OK." + - "Network or logs: The browser sends an image upload request, or backend logs show the local-agent input contains an image." + - "Console: No unexpected frontend runtime errors appear during upload or Debug Chat." +evidence_required: + - ui + - screenshot + - console + - network + - backend_log +diagnostics: + - "If the model cannot process image input, repeat with a multimodal-capable model before diagnosing local-agent." + - "For RAG plus multimodal coverage, keep a KB bound and verify the image remains visible while the answer uses the KB sentinel." +troubleshooting: + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch + - provider-image-parse-error + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-nonstreaming-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-nonstreaming-debug-chat.yaml new file mode 100644 index 0000000..910032d --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-nonstreaming-debug-chat.yaml @@ -0,0 +1,64 @@ +id: local-agent-nonstreaming-debug-chat +title: "Local Agent Debug Chat returns a deterministic non-streaming response" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: medium +ci_eligible: false +tags: + - local-agent + - pipeline + - non-streaming +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "Reply only NONSTREAM_OK." +automation_expected_text: "NONSTREAM_OK" +automation_stream_output: "0" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target local-agent pipeline." + - "Open Configuration > AI." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model that is known to answer Debug Chat in the current environment." + - "Save the pipeline." + - "Open Debug Chat." + - "Disable the stream switch when the UI exposes it." + - "Send: Reply only NONSTREAM_OK." +checks: + - "UI: The user message appears in Debug Chat." + - "UI: A Bot message appears and contains NONSTREAM_OK." + - "Logs: Backend completes the request as a normal response rather than only relying on the streaming-completed path." + - "Console: No unexpected frontend runtime errors appear during the send/receive path." +evidence_required: + - ui + - console + - backend_log +diagnostics: + - "If the UI still streams after the switch is disabled, inspect the adapter streaming capability and runner config before diagnosing the model." + - "Use GET /api/v1/pipelines/{uuid} only to confirm the saved runner and model config." +troubleshooting: + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml new file mode 100644 index 0000000..1c37e2f --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-plugin-tool-call-debug-chat.yaml @@ -0,0 +1,68 @@ +id: local-agent-plugin-tool-call-debug-chat +title: "Local Agent can call a plugin-provided tool" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - plugin + - tools + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "Call the qa_plugin_echo tool with exactly this text: plugin-tool-ok-local-agent. Return only the tool result." +automation_expected_text: "qa-plugin-smoke:plugin-tool-ok-local-agent" +automation_response_timeout_ms: "180000" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The selected model route supports function/tool calling." +steps: + - "Install or enable the bundled qa-plugin-smoke fixture plugin." + - "Confirm /api/v1/tools or the plugin detail shows qa_plugin_echo." + - "Confirm the fixture plugin is bound to the target pipeline through Extensions, or that all plugins are enabled." + - "Open the target local-agent pipeline." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model with function-calling ability that is known to work with tools in the current environment." + - "Open Debug Chat." + - "Send: Call the qa_plugin_echo tool with exactly this text: plugin-tool-ok-local-agent. Return only the tool result." +checks: + - "UI: Debug Chat bot response contains qa-plugin-smoke:plugin-tool-ok-local-agent." + - "Logs: Backend logs show the plugin tool call was executed, not only listed." + - "Console: No unexpected frontend errors appear during Debug Chat." +evidence_required: + - ui + - console + - backend_log + - api_diagnostic +diagnostics: + - "If qa_plugin_echo is not listed, rebuild and reinstall the qa-plugin-smoke fixture plugin." + - "If the selected model returns model_not_found or no available channel only when tools are provided, switch to a known-good function-calling model before diagnosing plugin tools or local-agent." +troubleshooting: + - local-agent-model-route-unavailable + - tool-name-collision-between-mcp-and-plugin + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-rag-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-rag-debug-chat.yaml new file mode 100644 index 0000000..b7e715b --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-rag-debug-chat.yaml @@ -0,0 +1,76 @@ +id: local-agent-rag-debug-chat +title: "Local Agent Debug Chat answers from a LangRAG knowledge base" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - langrag + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot/local-agent/default" +automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"]}' +automation_restore_runner_config: "1" +automation_reset_debug_chat: "1" +automation_prompt: "Using the knowledge base, what is the local agent runner retrieval sentinel? Return only the sentinel." +automation_expected_text: "azalea-cobalt-7421" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +preconditions: + - "The target pipeline already has a text-capable model route that is available for this run." +steps: + - "Ensure case langrag-kb-retrieve has produced a knowledge base containing sentinel azalea-cobalt-7421." + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target pipeline." + - "Open Configuration > AI." + - "Use runner Default and add the LangRAG knowledge base to Knowledge Bases." + - "Save the pipeline." + - "Open Debug Chat." + - "Send: Using the knowledge base, what is the local agent runner retrieval sentinel? Return only the sentinel." +checks: + - "UI: The AI config shows the selected knowledge base under Knowledge Bases." + - "UI: The pipeline saves successfully." + - "UI: Debug Chat shows a Bot message containing azalea-cobalt-7421." + - "Console: No unexpected frontend errors appear during configuration or chat." + - "Logs: Backend logs show the debug chat request completed instead of plugin/runtime timeout." + - "API diagnostic: pipeline-config-diagnostic.json shows knowledge-bases and model were temporarily patched." + - "API diagnostic: pipeline-config-restore-diagnostic.json shows the original runner config was restored." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "Use GET /api/v1/pipelines/{uuid} only to confirm the saved runner_config contains the knowledge base uuid." + - "If the bot ignores the knowledge base, rerun Retrieve Test before debugging the runner." +troubleshooting: + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-rag-multimodal-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-rag-multimodal-debug-chat.yaml new file mode 100644 index 0000000..4e07593 --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-rag-multimodal-debug-chat.yaml @@ -0,0 +1,74 @@ +id: local-agent-rag-multimodal-debug-chat +title: "Local Agent preserves image input while using RAG context" +mode: agent-browser +area: pipeline +type: regression +priority: p2 +risk: high +ci_eligible: false +tags: + - local-agent + - rag + - multimodal + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"]}' +automation_restore_runner_config: "1" +automation_reset_debug_chat: "1" +automation_prompt: "I attached an image. Using the knowledge base, what is the local agent runner retrieval sentinel? Return only the sentinel." +automation_expected_text: "azalea-cobalt-7421" +automation_image_base64_fixture: "skills/langbot-testing/fixtures/multimodal/red-square.png.base64" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID +preconditions: + - "The selected model route accepts image input, or the case is intentionally checking graceful provider rejection." +steps: + - "Ensure case langrag-kb-retrieve has produced a knowledge base containing sentinel azalea-cobalt-7421." + - "Prepare a PNG file for upload. Prefer the bundled fixture at skills/langbot-testing/fixtures/multimodal/red-square.png.base64." + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines and open the target local-agent pipeline." + - "Open Debug Chat." + - "Attach the PNG through the image/file upload control." + - "Send: I attached an image. Use the knowledge base and reply only the unique sentinel from the knowledge base." +checks: + - "UI: The sent User message shows an image attachment." + - "UI: The Bot message contains the expected KB sentinel." + - "Logs: The same backend request line contains [Image], and the request completes without runner.llm_error." + - "Console: No unexpected frontend runtime errors appear during upload or Debug Chat." +evidence_required: + - ui + - screenshot + - console + - backend_log +diagnostics: + - "This case does not require the model to identify image content; provider vision quality is not the local-agent runner contract." + - "If the provider rejects the image, retest with the 64x64 red-square fixture and a known multimodal-capable model route." + - "If the image is visible but the sentinel is absent, first verify the KB is bound and retrievable in local-agent-rag-debug-chat." +troubleshooting: + - plugin-runtime-timeout + - proxy-env-mismatch + - provider-image-parse-error + - survey-widget-blocks-debug-chat diff --git a/skills/skills/langbot-testing/cases/local-agent-steering-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-steering-debug-chat.yaml new file mode 100644 index 0000000..973822f --- /dev/null +++ b/skills/skills/langbot-testing/cases/local-agent-steering-debug-chat.yaml @@ -0,0 +1,87 @@ +id: local-agent-steering-debug-chat +title: "Local Agent injects a follow-up into an active run" +mode: agent-browser +area: pipeline +type: regression +priority: p1 +risk: high +ci_eligible: false +tags: + - local-agent + - pipeline + - steering + - tools +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/local-agent-steering-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_runner_id: "plugin:langbot/local-agent/default" +automation_reset_debug_chat: "1" +automation_stream_output: "1" +automation_prompt: "You are running the LangBot steering E2E test. First call the qa_plugin_sleep tool with seconds=8 and text=steering-e2e-anchor. Do not answer before the tool result is available. After the tool returns, answer the latest user follow-up. If no follow-up was injected, reply only STEERING_NO_FOLLOWUP." +automation_expected_text: "qa_steering_sentinel_6194" +automation_response_timeout_ms: "240000" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "case:qa-plugin-smoke-live-install" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "The selected model route supports function/tool calling and can call qa_plugin_sleep." +steps: + - "Open the target local-agent pipeline." + - "Assert the saved runner is plugin:langbot/local-agent/default." + - "Reset the person Debug Chat session for the target pipeline." + - "Assert /api/v1/tools contains qa_plugin_sleep." + - "Send the first prompt that instructs the model to call qa_plugin_sleep for 8 seconds." + - "After 1 second, send a second Debug Chat message containing qa_steering_sentinel_6194 while the first run is still active." + - "Wait for the final assistant response." +checks: + - "UI: Debug Chat shows two new user messages but only one new assistant response." + - "UI: The only new assistant response contains qa_steering_sentinel_6194." + - "API diagnostic: pipeline-config-diagnostic.json confirms the local-agent runner id." + - "API diagnostic: tool-diagnostic.json confirms qa_plugin_sleep is exposed." + - "Logs: Backend handles the follow-up without starting an independent second assistant response." + - "Console: No unexpected frontend runtime errors appear during Debug Chat." +evidence_required: + - ui + - screenshot + - console + - backend_log + - api_diagnostic +diagnostics: + - "If qa_plugin_sleep is missing, rebuild and reinstall or resync the qa-plugin-smoke fixture plugin, then restart the backend." + - "If the UI cannot send the follow-up because the input is disabled during a run, the WebUI path does not yet support steering." + - "If two assistant responses appear, the follow-up likely started a separate run instead of being claimed as steering." + - "If the model replies STEERING_NO_FOLLOWUP, inspect Host steering claim logs and local-agent steering_pull calls." +success_patterns: + - "Processing request from person_websocket" + - "Steering" + - "Streaming completed" +failure_patterns: + - "STEERING_NO_FOLLOWUP" + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Task exception was never retrieved" + - "survey widget blocks debug chat" +troubleshooting: + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch + - survey-widget-blocks-debug-chat + - debug-chat-history-contaminates-automation diff --git a/skills/skills/langbot-testing/cases/mcp-stdio-register.yaml b/skills/skills/langbot-testing/cases/mcp-stdio-register.yaml new file mode 100644 index 0000000..6c9857b --- /dev/null +++ b/skills/skills/langbot-testing/cases/mcp-stdio-register.yaml @@ -0,0 +1,59 @@ +id: mcp-stdio-register +title: "MCP stdio fixture is registered and exposes qa_mcp_echo" +mode: agent-browser +area: mcp +type: smoke +priority: p1 +risk: medium +ci_eligible: false +tags: + - mcp + - tools + - fixture + - preflight +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +automation: scripts/e2e/mcp-stdio-register.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +preconditions: + - "The active LangBot instance is safe to create or update the qa-local-stdio MCP server." + - "box.local.allowed_mount_roots allows the bundled MCP fixture path when Box runs stdio MCP servers." + - "The bundled dependency-free fixture skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py exists." +steps: + - "Open LANGBOT_FRONTEND_URL with the configured browser profile." + - "Use the browser token to upsert MCP server qa-local-stdio in stdio mode." + - "Point the server command to python with the bundled qa_mcp_echo_server.py fixture path." + - "Poll /api/v1/tools and /api/v1/mcp/servers/qa-local-stdio until qa_mcp_echo is visible." +checks: + - "API diagnostic: qa-local-stdio runtime_status is connected." + - "API diagnostic: runtime_tool_names includes qa_mcp_echo." + - "API diagnostic: /api/v1/tools includes qa_mcp_echo." + - "Console and network logs contain no unexpected frontend/runtime failures." +evidence_required: + - screenshot + - console + - network + - api_diagnostic +diagnostics: + - "If the server does not connect, run node scripts/e2e/mcp-stdio-fixture.mjs to validate the fixture outside LangBot first." + - "If /api/v1/tools keeps showing an old MCP name, rerun this case after restarting LangBot so the server registry is fresh." + - "If Box reports an allowed mount root error, update the local LangBot data config rather than changing shared test assets." +success_patterns: + - "MCP server qa-local-stdio is connected and exposes qa_mcp_echo" +failure_patterns: + - "MCP fixture not found" + - "Browser profile has no localStorage token" + - "did not expose qa_mcp_echo" +troubleshooting: + - mcp-stdio-args-not-applied + - backend-not-listening + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml b/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml new file mode 100644 index 0000000..593eecb --- /dev/null +++ b/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml @@ -0,0 +1,88 @@ +id: mcp-stdio-tool-call +title: "MCP stdio server exposes a tool that Local Agent can call" +mode: agent-browser +area: mcp +type: feature +priority: p1 +risk: high +ci_eligible: false +tags: + - mcp + - tools + - local-agent +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_prompt: "Call the qa_mcp_echo MCP tool with exactly this text: mcp-ok-local-agent. Return only the tool result." +automation_expected_text: "qa_mcp_echo:mcp-ok-local-agent" +automation_response_timeout_ms: "180000" +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" + - "case:mcp-stdio-register" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +failure_patterns: + - "qa-plugin-smoke:mcp-ok-local-agent" + - "qa_echo:mcp-ok-local-agent" + - "Agent runner temporarily unavailable" + - "runner.llm_error" + - "model_not_found" + - "no available channel for model" +preconditions: + - "box.local.allowed_mount_roots includes the bundled MCP fixture directory when LangBot runs stdio MCP servers through Box." + - "The selected model route supports function/tool calling." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to MCP Servers." + - "Create a Stdio MCP server named qa-local-stdio." + - "Set command to python." + - "Add one argument: the absolute path to skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py." + - "Click Test and wait for the test task to finish." + - "Submit the server." + - "Confirm the server detail page shows Tools: 1 and qa_mcp_echo." + - "Open the target local-agent pipeline." + - "Use runner Default or the pluginized langbot/local-agent runner." + - "Select a model with function-calling ability that is known to work with tools in the current environment." + - "Open Debug Chat." + - "Send: Call the qa_mcp_echo tool with exactly this text: mcp-ok-local-agent. Return only the tool result." +checks: + - "UI: The MCP server is connected after submit." + - "UI: The MCP detail page shows qa_mcp_echo." + - "API diagnostic: /api/v1/tools contains qa_mcp_echo." + - "UI: Debug Chat bot response contains qa_mcp_echo:mcp-ok-local-agent." + - "Logs: Backend logs show the MCP tool call was executed, not only listed." + - "Console: No unexpected frontend errors appear during MCP form use or Debug Chat." +evidence_required: + - ui + - console + - backend_log + - api_diagnostic +diagnostics: + - "Run node scripts/e2e/mcp-stdio-fixture.mjs to verify the bundled stdio fixture can list and call qa_mcp_echo without involving a model provider." + - "Run node scripts/e2e/mcp-stdio-register.mjs to upsert qa-local-stdio in LangBot and verify /api/v1/tools exposes qa_mcp_echo." + - "If backend logs show host_path is outside allowed_mount_roots, add the fixture directory to box.local.allowed_mount_roots in the local LangBot data config." + - "If backend logs show uv: not found, refresh qa-local-stdio with command python instead of uv." + - "If /api/v1/tools still shows qa_echo instead of qa_mcp_echo, refresh qa-local-stdio with the register diagnostic or restart the backend." + - "If Debug Chat cannot click Send, close the survey widget or other overlays before retrying." + - "If the model returns model_not_found or no available channel only when tools are provided, switch to a known-good function-calling model before diagnosing MCP or local-agent." +troubleshooting: + - mcp-stdio-args-not-applied + - tool-name-collision-between-mcp-and-plugin + - local-agent-model-route-unavailable + - survey-widget-blocks-debug-chat + - plugin-runtime-timeout diff --git a/skills/skills/langbot-testing/cases/pipeline-debug-chat-performance.yaml b/skills/skills/langbot-testing/cases/pipeline-debug-chat-performance.yaml new file mode 100644 index 0000000..266cbb5 --- /dev/null +++ b/skills/skills/langbot-testing/cases/pipeline-debug-chat-performance.yaml @@ -0,0 +1,80 @@ +id: pipeline-debug-chat-performance +title: "Pipeline Debug Chat user-path performance probe" +mode: agent-browser +area: pipeline +type: performance +priority: p1 +risk: medium +ci_eligible: false +tags: + - performance + - pipeline + - debug-chat + - user-path + - metrics +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +env_any: + - LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_E2E_PROMPT + - LANGBOT_E2E_EXPECTED_TEXT + - LANGBOT_E2E_RESPONSE_TIMEOUT_MS +automation_env_any: + - LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME +automation_prompt: "请只回复 OK,用于性能测试。" +automation_expected_text: "OK" +automation_response_timeout_ms: "120000" +automation_reset_debug_chat: "true" +automation_debug_chat_response_p95_ms: "120000" +automation_debug_chat_max_error_rate: "0" +metrics_thresholds_json: '{"response_p95_ms":{"max":120000},"error_rate":{"max":0}}' +load_profile_json: '{"prompts":1,"browser":true,"path":"Pipeline Debug Chat","metric":"send-to-visible-completion"}' +setup_automation: + - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" +setup_provides_env: + - LANGBOT_PIPELINE_URL + - LANGBOT_PIPELINE_NAME +preconditions: + - "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME points to the pipeline intended for this Debug Chat performance run." + - "The target pipeline is safe to reset Debug Chat history for this run." + - "The target pipeline has a known-good runner/model; provider latency should be interpreted separately from LangBot overhead." +steps: + - "Open LANGBOT_FRONTEND_URL with the prepared browser profile." + - "Open the target pipeline and select Debug Chat." + - "Reset Debug Chat history through the backend API when configured." + - "Send the deterministic prompt and wait for the expected assistant response." +checks: + - "automation-result.json status is pass when the expected assistant response appears." + - "metrics_summary includes response_p50_ms, response_p95_ms, error_rate, and total_duration_ms." + - "thresholds_summary shows response_p95_ms and error_rate pass." +evidence_required: + - ui + - screenshot + - console + - network + - metrics +diagnostics: + - "This case measures browser-visible send-to-completion latency; it does not split provider latency from LangBot overhead." + - "Use backend logs and provider diagnostics to explain slow runs before calling them LangBot regressions." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "Task exception was never retrieved" + - "All models failed during streaming setup" +troubleshooting: + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/pipeline-debug-chat.yaml b/skills/skills/langbot-testing/cases/pipeline-debug-chat.yaml new file mode 100644 index 0000000..1f0c16f --- /dev/null +++ b/skills/skills/langbot-testing/cases/pipeline-debug-chat.yaml @@ -0,0 +1,64 @@ +id: pipeline-debug-chat +title: "Pipeline Debug Chat returns a bot response" +mode: agent-browser +area: pipeline +type: smoke +priority: p0 +risk: medium +ci_eligible: false +tags: + - smoke + - pipeline +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +env_any: + - LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_E2E_PROMPT + - LANGBOT_E2E_EXPECTED_TEXT +automation_env_any: + - LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME +automation_prompt: "请只回复 OK,用于前端调试测试。" +automation_expected_text: "OK" +preconditions: + - "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME points to the pipeline intended for this generic Debug Chat smoke run." + - "The target pipeline is safe to save or already configured with a known-good runner/model." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Pipelines." + - "Open the target pipeline." + - "Select Debug Chat." + - "Send a short deterministic prompt, such as 请只回复 OK,用于前端调试测试。" +checks: + - "UI: The page shows a User message containing the prompt." + - "UI: The page shows a Bot message containing the expected response, such as OK." + - "Console: No unexpected frontend errors appear during the interaction." + - "Visual: If screenshot/vision is available, the chat panel is not blank, overlapped, or visually broken." + - "Logs: Backend logs include Processing request from person_websocket and Streaming completed." +evidence_required: + - ui + - screenshot + - console + - backend_log +diagnostics: + - "If the UI does not show a Bot response, inspect console/network and backend logs before using API/curl diagnostics." + - "Use API/curl only to distinguish frontend display failure from backend/runtime failure." +success_patterns: + - "Processing request from person_websocket" + - "Streaming completed" +failure_patterns: + - "Action invoke_llm_stream call timed out" + - "Task exception was never retrieved" +troubleshooting: + - debug-chat-history-contaminates-automation + - local-agent-model-route-unavailable + - plugin-runtime-timeout + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/cases/plugin-e2e-smoke.yaml b/skills/skills/langbot-testing/cases/plugin-e2e-smoke.yaml new file mode 100644 index 0000000..87dd27e --- /dev/null +++ b/skills/skills/langbot-testing/cases/plugin-e2e-smoke.yaml @@ -0,0 +1,57 @@ +id: plugin-e2e-smoke +title: "Plugin system installs a local plugin and exposes tool/page APIs" +mode: agent-browser +area: plugin +type: smoke +priority: p1 +risk: medium +ci_eligible: false +tags: + - plugin + - runtime + - local-install + - e2e +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_REPO +preconditions: + - "LangBot is started from the target worktree with the SDK under test installed." + - "The active instance is a local test instance where installing or updating the qa-plugin-smoke fixture is acceptable." +steps: + - "Ensure LangBot was started from the target worktree with the SDK under test installed, and use uv run --no-sync so uv does not resync langbot-plugin from the lockfile." + - "Build the fixture plugin at skills/langbot-testing/fixtures/plugins/qa-plugin-smoke using the same local SDK under test." + - "Open LANGBOT_FRONTEND_URL." + - "Log in or initialize a local test account if the clean test instance is not initialized." + - "Navigate to Plugins." + - "Install a local plugin package using the generated qa-plugin-smoke zip file." + - "Wait for the plugin installation task to finish." + - "Confirm the installed plugin list shows QA Plugin Smoke with initialized or running status." + - "Open the plugin detail and confirm the qa_echo Tool, qa_plugin_echo Tool, Prompt Probe EventListener, and Smoke Page components are visible." + - "Open the Smoke Page from the plugin extension page entry if the UI exposes it." + - "Use the page or API diagnostic to call endpoint /ping and confirm the response sentinel qa-plugin-smoke-page." +checks: + - "UI: The plugin installation task finishes successfully and the plugin card appears." + - "UI: Plugin detail shows qa_echo, qa_plugin_echo, Prompt Probe, and Smoke Page components." + - "UI: The plugin extension page renders without a blank iframe or runtime error when reachable from the sidebar." + - "API diagnostic: /api/v1/plugins contains qa/plugin-smoke with status initialized." + - "API diagnostic: /api/v1/tools contains qa_echo and qa_plugin_echo from qa/plugin-smoke." + - "API diagnostic: /api/v1/plugins/qa/plugin-smoke/page-api with page_id smoke and endpoint /ping returns qa-plugin-smoke-page." + - "Logs: Backend logs show Connected to plugin runtime and no plugin action timeout during install/list/page-api." + - "Console: No unexpected frontend errors appear during plugin install or detail navigation." +evidence_required: + - ui + - console + - backend_log + - api_diagnostic +diagnostics: + - "Before trusting results, confirm Python imports langbot_plugin from the local SDK source path, not site-packages from PyPI." + - "If uv run changes the installed SDK back to the lockfile package, reinstall the local SDK and rerun commands with uv run --no-sync." + - "If local install stalls, inspect /api/v1/system/tasks/ and backend logs." +troubleshooting: + - plugin-runtime-timeout + - marketplace-network-flaky + - uv-run-resyncs-local-sdk diff --git a/skills/skills/langbot-testing/cases/provider-deepseek.yaml b/skills/skills/langbot-testing/cases/provider-deepseek.yaml new file mode 100644 index 0000000..eeb0e7d --- /dev/null +++ b/skills/skills/langbot-testing/cases/provider-deepseek.yaml @@ -0,0 +1,42 @@ +id: provider-deepseek +title: "DeepSeek provider can be configured and used" +mode: agent-browser +area: provider +type: provider +priority: p2 +risk: medium +ci_eligible: false +tags: + - provider + - model +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL +preconditions: + - "A DeepSeek-compatible API key and model route are available from the active secret source and must not be printed in reports." + - "A test pipeline is available for confirming the saved provider can answer Debug Chat." +steps: + - "Open LANGBOT_FRONTEND_URL." + - "Navigate to Models." + - "Add or edit a DeepSeek provider." + - "Fill the required base URL, API key, and model fields from the active secret source." + - "Run the provider or model test action in the UI." + - "Run a small Pipeline Debug Chat prompt to confirm the provider is usable." +checks: + - "UI: The provider/model test action succeeds in the page." + - "UI: A Pipeline Debug Chat prompt returns a Bot response after provider setup." + - "Console: No unexpected frontend errors appear while saving or testing the provider." + - "Secret safety: No API key, token, or secret is printed in logs or reports." +evidence_required: + - ui + - console + - backend_log +diagnostics: + - "If provider testing fails, inspect backend logs and provider error messages to distinguish invalid credentials from UI wiring issues." + - "Use API/curl only as a diagnostic aid after reproducing through the UI." +troubleshooting: + - proxy-env-mismatch + - plugin-runtime-timeout diff --git a/skills/skills/langbot-testing/cases/qa-plugin-smoke-live-install.yaml b/skills/skills/langbot-testing/cases/qa-plugin-smoke-live-install.yaml new file mode 100644 index 0000000..f0ec3c3 --- /dev/null +++ b/skills/skills/langbot-testing/cases/qa-plugin-smoke-live-install.yaml @@ -0,0 +1,44 @@ +id: qa-plugin-smoke-live-install +title: "QA plugin smoke package installs and exposes tools" +mode: probe +area: plugin +type: regression +priority: p1 +risk: medium +ci_eligible: false +tags: + - plugin + - local-install + - fixture +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_BACKEND_URL + - LANGBOT_REPO + - LANGBOT_E2E_LOGIN_USER +automation: scripts/e2e/install-qa-plugin-smoke.mjs +automation_plugin_package: "skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg" +automation_expected_plugin_id: "qa/plugin-smoke" +automation_expected_tool: "qa_plugin_echo" +steps: + - "Run `rtk bin/lbs test run qa-plugin-smoke-live-install --dry-run` first; remove `--dry-run` only after readiness points at a local test LangBot instance." + - "Automation authenticates the local test user, uploads the QA plugin smoke .lbpkg package when missing, waits for install, and checks tool exposure." +checks: + - "automation-result.json status is pass." + - "/api/v1/plugins lists qa/plugin-smoke after install." + - "/api/v1/tools lists qa_plugin_echo after install." +evidence_required: + - api_diagnostic + - filesystem +diagnostics: + - "This prepares prompt/tool/steering cases that depend on qa-plugin-smoke. It does not prove the model can call the tools." + - "If install fails during dependencies, inspect plugin runtime logs and plugin-dependency-install-offline." +success_patterns: + - "qa/plugin-smoke is installed." +failure_patterns: + - "Plugin install task did not complete successfully" + - "qa_plugin_echo is not listed" +troubleshooting: + - plugin-runtime-timeout + - plugin-dependency-install-offline diff --git a/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml new file mode 100644 index 0000000..91bb97f --- /dev/null +++ b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml @@ -0,0 +1,54 @@ +id: sandbox-skill-authoring-e2e +title: "Local Agent creates, registers, activates, and uses a sandbox skill" +mode: agent-browser +area: sandbox +type: regression +priority: p2 +risk: high +ci_eligible: false +tags: + - sandbox + - skills + - local-agent + - tools + - e2b + - nsjail +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +preconditions: + - "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test." + - "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail." + - "The selected model route supports tool/function calling strongly enough to invoke sandbox tools." +steps: + - "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend." + - "Open the target Local Agent pipeline in Debug Chat with a function-calling model." + - "Run the canonical prompt pattern in references/sandbox-skill-authoring.md with a unique skill name." + - "Capture the visible final response, backend logs, and the registered skill-store files." +checks: + - "UI: Debug Chat final assistant response contains E2E_OK:." + - "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path." + - "Logs: The selected backend name is the expected one, such as e2b or nsjail." + - "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md." + - "Box status: recent_error_count is 0 after the run." +evidence_required: + - ui + - backend_log + - api_diagnostic + - filesystem +diagnostics: + - "If Debug Chat fails before tool execution, first validate the model provider and function-calling support." + - "If /workspace/.skills/ is missing only on E2B, check extra mount sync behavior." + - "If nsjail starts but every command fails before execve, check nsjail CLI compatibility and chroot mount targets." + - "API or raw provider probes are diagnostic only; they do not make this UI case pass by themselves." +troubleshooting: + - sandbox-native-tools-unavailable + - e2b-extra-mount-sync-missing + - box-session-conflict-logical-metadata + - nsjail-cli-compatibility + - socks-proxy-without-socksio diff --git a/skills/skills/langbot-testing/cases/sandbox-skill-authoring-edit-existing-e2e.yaml b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-edit-existing-e2e.yaml new file mode 100644 index 0000000..af4c383 --- /dev/null +++ b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-edit-existing-e2e.yaml @@ -0,0 +1,71 @@ +id: sandbox-skill-authoring-edit-existing-e2e +title: "Local Agent modifies an activated sandbox skill package" +mode: agent-browser +area: sandbox +type: regression +priority: p2 +risk: high +ci_eligible: false +tags: + - sandbox + - skills + - local-agent + - tools + - edit +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_REPO + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation: scripts/e2e/pipeline-debug-chat.mjs +automation_env: + - LANGBOT_REPO + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE + - LANGBOT_LOCAL_AGENT_PIPELINE_URL + - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL +automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME +automation_expected_text: "UPDATED_MARKER_EDIT_REGRESSION" +automation_prompts_json: '[{"prompt":"Skill name is lb-skill-edit-regression. Use exactly one exec tool call with workdir=/workspace. In that single command, remove old /workspace/lb-skill-edit-regression, create SKILL.md, scripts/use.py, and data/input.json. The JSON must contain numbers [1,2,3,4], factors [2,3,4], and marker CREATE_MARKER_EDIT_REGRESSION. The Python script must read data/input.json relative to __file__ and print exactly SANDBOX_SKILL_CREATE_OK sum=10 product=24 marker=CREATE_MARKER_EDIT_REGRESSION. Run python3 /workspace/lb-skill-edit-regression/scripts/use.py in the same command. After the exec succeeds, final answer exactly CREATE_OK:lb-skill-edit-regression.","expected_text":"CREATE_MARKER_EDIT_REGRESSION","response_timeout_ms":"180000"},{"prompt":"Continue with the same skill name lb-skill-edit-regression. Strictly call register_skill with path=/workspace/lb-skill-edit-regression and name=lb-skill-edit-regression, then call activate with skill_name=lb-skill-edit-regression. Do not call exec, read, write, edit, grep, or ls in this step. After both tools succeed, final answer exactly REGISTER_ACTIVATE_OK:lb-skill-edit-regression.","expected_text":"REGISTER_ACTIVATE_OK:lb-skill-edit-regression","response_timeout_ms":"180000"},{"prompt":"Continue with the same activated skill lb-skill-edit-regression. Use exactly one exec tool call with workdir=/workspace/.skills/lb-skill-edit-regression. In that single exec command, run exactly this shell logic: set -e; overwrite SKILL.md with a heading and UPDATED_MARKER_EDIT_REGRESSION; overwrite data/input.json with numbers [5,6], factors [7,8], and marker UPDATED_MARKER_EDIT_REGRESSION; overwrite scripts/use.py so it reads data/input.json relative to __file__ and prints exactly SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker=UPDATED_MARKER_EDIT_REGRESSION; run python3 scripts/use.py. Do not write exit(1). Do not call find, ls, read, grep as separate tools. If the single exec exits successfully and prints SANDBOX_SKILL_MODIFIED_OK, do not call more tools. Final answer exactly E2E_OK:lb-skill-edit-regression:SANDBOX_SKILL_MODIFIED_OK.","expected_text":"UPDATED_MARKER_EDIT_REGRESSION","response_timeout_ms":"240000"}]' +automation_filesystem_checks_json: '[{"path":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression/SKILL.md","contains":"UPDATED_MARKER_EDIT_REGRESSION"},{"path":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression/data/input.json","contains":["UPDATED_MARKER_EDIT_REGRESSION","\"numbers\": [5, 6]","\"factors\": [7, 8]"]},{"path":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression/scripts/use.py","contains":["SANDBOX_SKILL_MODIFIED_OK"],"not_contains":["SANDBOX_SKILL_CREATE_OK","exit(1)"]},{"argv":["python3","scripts/use.py"],"cwd":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression","stdout_contains":"SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker=UPDATED_MARKER_EDIT_REGRESSION","exit_code":0}]' +automation_stream_output: "0" +automation_reset_debug_chat: "1" +automation_response_timeout_ms: "420000" +preconditions: + - "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test." + - "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail." + - "The selected model route supports tool/function calling strongly enough to invoke sandbox tools." +steps: + - "Start LangBot with the target sandbox backend and confirm native sandbox tools are available." + - "Open the target Local Agent pipeline in Debug Chat with a function-calling model." + - "Run the automation prompt or the equivalent prompt pattern from references/sandbox-skill-authoring.md." + - "Capture the visible final response, backend logs, and the registered skill-store files." +checks: + - "UI: A Bot message, not only the user prompt, contains UPDATED_MARKER_EDIT_REGRESSION." + - "Logs: The model called exec, register_skill, activate, then a second exec whose workdir is /workspace/.skills/lb-skill-edit-regression." + - "Logs: The second exec stdout contains SANDBOX_SKILL_MODIFIED_OK and GREP_ALL_OK or equivalent grep success." + - "Automation: Filesystem checks pass for the registered skill-store files under LANGBOT_REPO/data/box/skills/lb-skill-edit-regression." + - "Skill store: SKILL.md and data/input.json contain UPDATED_MARKER_EDIT_REGRESSION." + - "Skill store: scripts/use.py is the modified script and prints SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker=UPDATED_MARKER_EDIT_REGRESSION." +evidence_required: + - ui + - backend_log + - api_diagnostic + - filesystem +diagnostics: + - "If the model stops after activation and only reruns the original script, treat this as a failed edit-existing E2E even if create/register/activate succeeded." + - "If the expected marker appears only in the user prompt, treat the run as failed and inspect automation-result.json assistant counts." + - "If the browser opens /login or console shows 401, refresh the authenticated browser profile before diagnosing the runner." +troubleshooting: + - sandbox-native-tools-unavailable + - e2b-extra-mount-sync-missing + - box-session-conflict-logical-metadata + - nsjail-cli-compatibility + - socks-proxy-without-socksio diff --git a/skills/skills/langbot-testing/cases/webui-login-state.yaml b/skills/skills/langbot-testing/cases/webui-login-state.yaml new file mode 100644 index 0000000..50119b1 --- /dev/null +++ b/skills/skills/langbot-testing/cases/webui-login-state.yaml @@ -0,0 +1,41 @@ +id: webui-login-state +title: "Configured frontend opens with authenticated LangBot WebUI state" +mode: agent-browser +area: auth +type: smoke +priority: p0 +risk: low +ci_eligible: false +tags: + - smoke + - auth +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE +automation: scripts/e2e/webui-login-state.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +steps: + - "Open LANGBOT_FRONTEND_URL with the configured browser-control path." + - "Confirm the page is not stuck on /login." + - "Check that the sidebar or dashboard shows a logged-in user." + - "If login is missing, use langbot-env-setup OAuth browser profile guidance." +checks: + - "UI: The WebUI shows LangBot navigation such as Dashboard, Bots, Pipelines, or Knowledge." + - "UI: The page is not stuck on /login after the configured profile is loaded." + - "Console: No unexpected frontend errors appear during initial load." + - "Secret safety: The agent does not print token values while checking auth state." +evidence_required: + - ui + - screenshot + - console +diagnostics: + - "If the page stays on /login, inspect only localStorage key names and origin mismatch; do not print token values." +troubleshooting: + - proxy-env-mismatch diff --git a/skills/skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json b/skills/skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json new file mode 100644 index 0000000..b2f1533 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json @@ -0,0 +1,38 @@ +{ + "id": "qa-agent-runner-behaviors", + "behaviors": [ + { + "name": "ok", + "results": [ + {"type": "message.completed", "data": {"message": {"role": "assistant", "content": "QA_RUNNER_OK"}}}, + {"type": "run.completed", "data": {"finish_reason": "stop"}} + ] + }, + { + "name": "stream_ok", + "results": [ + {"type": "message.delta", "data": {"chunk": {"role": "assistant", "content": "QA_"}}}, + {"type": "message.delta", "data": {"chunk": {"role": "assistant", "content": "RUNNER_STREAM_OK"}}}, + {"type": "run.completed", "data": {"finish_reason": "stop"}} + ] + }, + { + "name": "empty_output", + "results": [ + {"type": "run.completed", "data": {"finish_reason": "stop"}} + ] + }, + { + "name": "malformed_result", + "results": [ + {"type": "message.completed", "data": {}} + ] + }, + { + "name": "controlled_failure", + "results": [ + {"type": "run.failed", "data": {"error": "QA runner controlled failure", "code": "qa.failure", "retryable": false}} + ] + } + ] +} diff --git a/skills/skills/langbot-testing/fixtures/fixtures.json b/skills/skills/langbot-testing/fixtures/fixtures.json new file mode 100644 index 0000000..70badac --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/fixtures.json @@ -0,0 +1,93 @@ +[ + { + "id": "qa-agent-runner-behaviors", + "title": "Deterministic AgentRunner behavior matrix", + "kind": "json", + "path": "fixtures/agent-runner/qa-runner-behaviors.json", + "related_cases": [ + "agent-runner-behavior-matrix", + "agent-runner-ledger-invariants", + "agent-runner-runtime-chaos" + ], + "checks": ["exists"] + }, + { + "id": "qa-agent-runner-source", + "title": "QA deterministic AgentRunner fixture source", + "kind": "plugin_source", + "path": "fixtures/plugins/qa-agent-runner/manifest.yaml", + "related_cases": [ + "agent-runner-fixture-contract", + "agent-runner-behavior-matrix", + "agent-runner-live-install", + "agent-runner-qa-debug-chat" + ], + "checks": ["exists", "qa_agent_runner_source"] + }, + { + "id": "qa-agent-runner-package", + "title": "QA deterministic AgentRunner prebuilt package", + "kind": "plugin_package", + "path": "fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg", + "related_cases": [ + "agent-runner-fixture-contract", + "agent-runner-live-install", + "agent-runner-qa-debug-chat" + ], + "checks": ["exists", "zip_package"] + }, + { + "id": "mcp-stdio-echo-server", + "title": "MCP stdio qa_mcp_echo server", + "kind": "python", + "path": "fixtures/mcp/qa_mcp_echo_server.py", + "related_cases": ["mcp-stdio-tool-call"], + "checks": ["exists", "direct_mcp_script", "langbot_registration_script"] + }, + { + "id": "rag-sentinel-doc", + "title": "LangRAG sentinel text document", + "kind": "text", + "path": "fixtures/rag/sentinel-doc.txt", + "related_cases": ["langrag-kb-retrieve", "local-agent-rag-debug-chat"], + "checks": ["exists"] + }, + { + "id": "rag-parser-golden-html", + "title": "LangRAG parser golden HTML document", + "kind": "html", + "path": "fixtures/rag/parser-golden.html", + "related_cases": ["langrag-parser-golden-e2e"], + "checks": ["exists"] + }, + { + "id": "multimodal-red-square", + "title": "64x64 red-square image fixture", + "kind": "base64_png", + "path": "fixtures/multimodal/red-square.png.base64", + "related_cases": ["local-agent-multimodal-debug-chat", "local-agent-rag-multimodal-debug-chat"], + "checks": ["exists"] + }, + { + "id": "qa-plugin-smoke-source", + "title": "QA plugin smoke fixture source", + "kind": "plugin_source", + "path": "fixtures/plugins/qa-plugin-smoke/manifest.yaml", + "related_cases": [ + "qa-plugin-smoke-live-install", + "plugin-e2e-smoke", + "local-agent-effective-prompt-debug-chat", + "local-agent-plugin-tool-call-debug-chat", + "local-agent-steering-debug-chat" + ], + "checks": ["exists"] + }, + { + "id": "qa-plugin-smoke-package", + "title": "QA plugin smoke prebuilt package", + "kind": "plugin_package", + "path": "fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg", + "related_cases": ["qa-plugin-smoke-live-install", "plugin-e2e-smoke"], + "checks": ["exists", "zip_package"] + } +] diff --git a/skills/skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py b/skills/skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py new file mode 100644 index 0000000..44e04b8 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import sys +import typing + +SERVER_INFO = {"name": "langbot-qa-stdio", "version": "0.1.0"} +TOOL_NAME = "qa_mcp_echo" + + +def _write_message(message: dict[str, typing.Any]) -> None: + sys.stdout.write( + json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n" + ) + sys.stdout.flush() + + +def _result( + message_id: typing.Any, result: dict[str, typing.Any] +) -> dict[str, typing.Any]: + return {"jsonrpc": "2.0", "id": message_id, "result": result} + + +def _error(message_id: typing.Any, code: int, message: str) -> dict[str, typing.Any]: + return { + "jsonrpc": "2.0", + "id": message_id, + "error": { + "code": code, + "message": message, + }, + } + + +def _tool_schema() -> dict[str, typing.Any]: + return { + "name": TOOL_NAME, + "description": "Return a deterministic QA echo string.", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to include in the QA echo response.", + }, + }, + "required": ["text"], + "additionalProperties": False, + }, + } + + +def handle_message(message: dict[str, typing.Any]) -> dict[str, typing.Any] | None: + message_id = message.get("id") + method = str(message.get("method") or "") + params = message.get("params") or {} + if not isinstance(params, dict): + params = {} + + if message_id is None: + return None + + if method == "initialize": + return _result( + message_id, + { + "protocolVersion": str(params.get("protocolVersion") or "2024-11-05"), + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": SERVER_INFO, + }, + ) + + if method == "ping": + return _result(message_id, {}) + + if method == "tools/list": + return _result(message_id, {"tools": [_tool_schema()]}) + + if method == "tools/call": + name = str(params.get("name") or "") + arguments = params.get("arguments") or {} + if not isinstance(arguments, dict): + arguments = {} + if name != TOOL_NAME: + return _error(message_id, -32602, f"Unknown tool: {name}") + text = str(arguments.get("text") or "") + return _result( + message_id, + { + "content": [ + { + "type": "text", + "text": f"{TOOL_NAME}:{text}", + } + ], + "isError": False, + }, + ) + + return _error(message_id, -32601, f"Method not found: {method}") + + +def main() -> int: + for raw_line in sys.stdin: + line = raw_line.strip() + if not line: + continue + try: + message = json.loads(line) + except json.JSONDecodeError as exc: + _write_message(_error(None, -32700, f"Parse error: {exc}")) + continue + if not isinstance(message, dict): + _write_message(_error(None, -32600, "Invalid request")) + continue + response = handle_message(message) + if response is not None: + _write_message(response) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/skills/langbot-testing/fixtures/multimodal/red-pixel.png.base64 b/skills/skills/langbot-testing/fixtures/multimodal/red-pixel.png.base64 new file mode 100644 index 0000000..c8e6472 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/multimodal/red-pixel.png.base64 @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR4nGP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg== diff --git a/skills/skills/langbot-testing/fixtures/multimodal/red-square.png.base64 b/skills/skills/langbot-testing/fixtures/multimodal/red-square.png.base64 new file mode 100644 index 0000000..808a834 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/multimodal/red-square.png.base64 @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAT0lEQVR42u3PQQkAAAgEsIty/TMZxgi+hcEKLNO+FgEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQGBywIJs8EAKp/R7QAAAABJRU5ErkJggg== diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/README.md b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/README.md new file mode 100644 index 0000000..cbde4cc --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/README.md @@ -0,0 +1,15 @@ +# QA AgentRunner Fixture + +Deterministic AgentRunner plugin source used by `langbot-skills` probes and future browser release-gate cases. + +Runner id after installation should be: + +```text +plugin:qa/agent-runner/default +``` + +Expected behavior: + +- normal input returns `QA_AGENT_RUNNER_OK:` +- input containing `stream` emits streaming chunks then completes +- input containing `fail` returns `QA_AGENT_RUNNER_CONTROLLED_FAILURE` diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/assets/icon.svg b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/assets/icon.svg new file mode 100644 index 0000000..2a0e899 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/assets/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py new file mode 100644 index 0000000..7d2fd37 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import typing + +from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner +from langbot_plugin.api.entities.builtin.agent_runner import AgentRunContext, AgentRunResult +from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk + + +class DefaultAgentRunner(AgentRunner): + async def run( + self, + ctx: AgentRunContext, + ) -> typing.AsyncGenerator[AgentRunResult, None]: + text = (ctx.input.to_text() or "").strip() + if "fail" in text.lower(): + yield AgentRunResult.run_failed( + ctx.run_id, + error="QA_AGENT_RUNNER_CONTROLLED_FAILURE", + code="qa.controlled_failure", + retryable=False, + ) + return + + content = f"QA_AGENT_RUNNER_OK:{text or 'empty'}" + if "stream" in text.lower(): + for chunk in ("QA_", "AGENT_", f"RUNNER_OK:{text}"): + yield AgentRunResult.message_delta( + ctx.run_id, + MessageChunk(role="assistant", content=chunk), + ) + yield AgentRunResult.run_completed(ctx.run_id, finish_reason="stop") + return + + yield AgentRunResult.run_completed( + ctx.run_id, + Message(role="assistant", content=content), + finish_reason="stop", + ) diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.yaml new file mode 100644 index 0000000..a2fd64d --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.yaml @@ -0,0 +1,30 @@ +apiVersion: langbot/v1 +kind: AgentRunner +metadata: + name: default + label: + en_US: QA Deterministic Runner + zh_Hans: QA 确定性 Runner + description: + en_US: Deterministic runner fixture that returns stable QA sentinel output. + zh_Hans: 返回稳定 QA 哨兵输出的确定性 runner 夹具。 +spec: + capabilities: + streaming: true + tool_calling: false + knowledge_retrieval: false + multimodal_input: false + skill_authoring: false + interrupt: false + permissions: + models: [] + tools: [] + knowledge_bases: [] + history: [] + events: [] + storage: [] + config: [] +execution: + python: + path: default.py + attr: DefaultAgentRunner diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg new file mode 100644 index 0000000..aba6ead Binary files /dev/null and b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg differ diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/main.py b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/main.py new file mode 100644 index 0000000..6e1c392 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/main.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from langbot_plugin.api.definition.plugin import BasePlugin + + +class QAAgentRunnerPlugin(BasePlugin): + async def initialize(self) -> None: + self.ready_marker = "qa-agent-runner-ready" diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/manifest.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/manifest.yaml new file mode 100644 index 0000000..a7fd08c --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/manifest.yaml @@ -0,0 +1,25 @@ +apiVersion: langbot/v1 +kind: Plugin +metadata: + author: qa + name: agent-runner + repository: https://example.invalid/langbot/qa-agent-runner + version: 0.1.0 + description: + en_US: Deterministic AgentRunner fixture for LangBot QA. + zh_Hans: LangBot QA 使用的确定性 AgentRunner 夹具。 + label: + en_US: QA AgentRunner + zh_Hans: QA AgentRunner + icon: assets/icon.svg +spec: + config: [] + components: + AgentRunner: + fromDirs: + - path: components/agent_runner/ + maxDepth: 1 +execution: + python: + path: main.py + attr: QAAgentRunnerPlugin diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/.gitignore b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/.gitignore new file mode 100644 index 0000000..89d8e50 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/.gitignore @@ -0,0 +1,3 @@ +dist/* +!dist/ +!dist/qa-plugin-smoke-0.1.0.lbpkg diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/README.md b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/README.md new file mode 100644 index 0000000..e276f57 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/README.md @@ -0,0 +1,9 @@ +# QA Plugin Smoke + +Local fixture plugin for LangBot plugin E2E smoke testing. + +Tools: + +- `qa_echo(text)` returns `qa-plugin-smoke:`. +- `qa_plugin_echo(text)` returns `qa-plugin-smoke:`. +- `qa_plugin_sleep(seconds, text)` waits up to 15 seconds and returns `qa-plugin-smoke:sleep::`. diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/assets/icon.svg b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/assets/icon.svg new file mode 100644 index 0000000..440e9c8 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.py new file mode 100644 index 0000000..c2b06d0 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import Any + +from langbot_plugin.api.definition.components.common.event_listener import EventListener +from langbot_plugin.api.entities import context, events +from langbot_plugin.api.entities.builtin.provider.message import Message + + +def _content_text(content: Any) -> str: + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + + parts: list[str] = [] + for item in content: + text = item.get("text") if isinstance(item, dict) else getattr(item, "text", None) + if text: + parts.append(str(text)) + return "".join(parts) + + +def _message_text(message: Any) -> str: + if message is None: + return "" + return _content_text(getattr(message, "content", None)) + + +def _message_chain_text(message_chain: Any) -> str: + if message_chain is None: + return "" + try: + return str(message_chain) + except Exception: + return "" + + +async def _current_user_text(event_context: context.EventContext) -> str: + try: + query_var_text = await event_context.get_query_var("user_message_text") + except Exception: + query_var_text = None + if query_var_text: + return str(query_var_text) + + query = getattr(event_context.event, "query", None) + if query is not None: + message_chain_text = _message_chain_text(getattr(query, "message_chain", None)) + if message_chain_text: + return message_chain_text + + user_message_text = _message_text(getattr(query, "user_message", None)) + if user_message_text: + return user_message_text + + return "\n".join( + text for text in (_message_text(message) for message in event_context.event.prompt) if text + ) + + +class PromptProbeEventListener(EventListener): + async def initialize(self) -> None: + await super().initialize() + + @self.handler(events.PromptPreProcessing) + async def on_prompt_pre_processing(event_context: context.EventContext) -> None: + if "qa-effective-prompt" not in await _current_user_text(event_context): + return + + event_context.event.default_prompt.append( + Message( + role="system", + content=( + "QA prompt probe: if the current user message contains " + "qa-effective-prompt, reply only PROMPT_PREPROCESS_OK." + ), + ) + ) diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.yaml new file mode 100644 index 0000000..1e1cf0f --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/events/prompt_probe.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: EventListener +metadata: + name: prompt_probe + label: + en_US: Prompt Probe + zh_Hans: Prompt 探针 +spec: +execution: + python: + path: prompt_probe.py + attr: PromptProbeEventListener diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/en_US.json b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/en_US.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/en_US.json @@ -0,0 +1 @@ +{} diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/zh_Hans.json b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/zh_Hans.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/i18n/zh_Hans.json @@ -0,0 +1 @@ +{} diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/index.html b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/index.html new file mode 100644 index 0000000..f7a9ad2 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/index.html @@ -0,0 +1,43 @@ + + + + + + Smoke Page + + + +
+

Smoke Page

+

qa-plugin-smoke-page

+

waiting

+
+ + + + diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.py new file mode 100644 index 0000000..4e6d262 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from langbot_plugin.api.definition.components.page import Page, PageRequest, PageResponse + + +class SmokePage(Page): + async def handle_api(self, request: PageRequest) -> PageResponse: + return PageResponse.ok( + { + "sentinel": "qa-plugin-smoke-page", + "endpoint": request.endpoint, + "method": request.method, + "body": request.body, + } + ) diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.yaml new file mode 100644 index 0000000..83c9eb7 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/pages/smoke/smoke.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Page +metadata: + name: smoke + label: + en_US: Smoke Page + zh_Hans: 冒烟测试页 + description: + en_US: Page component for plugin e2e smoke testing. + zh_Hans: 插件端到端冒烟测试页面组件。 +spec: + path: index.html +execution: + python: + path: smoke.py + attr: SmokePage diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.py new file mode 100644 index 0000000..69e0fb2 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.api.entities.builtin.provider import session as provider_session + + +class QAEchoTool(Tool): + async def call( + self, + params: dict[str, Any], + session: provider_session.Session, + query_id: int, + ) -> str: + text = str(params.get("text", "")) + return f"qa-plugin-smoke:{text}" diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.yaml new file mode 100644 index 0000000..2775947 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_echo.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Tool +metadata: + name: qa_echo + label: + en_US: QA Echo + zh_Hans: QA 回显 + description: + en_US: Echoes deterministic text for plugin smoke testing. + zh_Hans: 为插件冒烟测试回显确定性文本。 +spec: + parameters: + type: object + properties: + text: + type: string + description: Text to echo. + required: + - text + llm_prompt: Echo deterministic text for plugin smoke testing. +execution: + python: + path: qa_echo.py + attr: QAEchoTool diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.py new file mode 100644 index 0000000..3432180 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.api.entities.builtin.provider import session as provider_session + + +class QAPluginEchoTool(Tool): + async def call( + self, + params: dict[str, Any], + session: provider_session.Session, + query_id: int, + ) -> str: + text = str(params.get("text", "")) + return f"qa-plugin-smoke:{text}" diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.yaml new file mode 100644 index 0000000..73ea93e --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_echo.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Tool +metadata: + name: qa_plugin_echo + label: + en_US: QA Plugin Echo + zh_Hans: QA 插件回显 + description: + en_US: Echoes deterministic text with a plugin-specific tool name. + zh_Hans: 使用插件专属工具名回显确定性文本。 +spec: + parameters: + type: object + properties: + text: + type: string + description: Text to echo. + required: + - text + llm_prompt: Echo deterministic text with qa-plugin-smoke prefix for plugin tool testing. +execution: + python: + path: qa_plugin_echo.py + attr: QAPluginEchoTool diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.py new file mode 100644 index 0000000..1d0d97d --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from langbot_plugin.api.definition.components.tool.tool import Tool +from langbot_plugin.api.entities.builtin.provider import session as provider_session + + +class QAPluginSleepTool(Tool): + async def call( + self, + params: dict[str, Any], + session: provider_session.Session, + query_id: int, + ) -> str: + raw_seconds = params.get("seconds", 0) + try: + seconds = float(raw_seconds) + except (TypeError, ValueError): + seconds = 0.0 + seconds = max(0.0, min(seconds, 15.0)) + text = str(params.get("text", "")) + await asyncio.sleep(seconds) + seconds_label = str(int(seconds)) if seconds.is_integer() else str(seconds) + return f"qa-plugin-smoke:sleep:{seconds_label}:{text}" diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.yaml new file mode 100644 index 0000000..6bc9bc9 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/components/tools/qa_plugin_sleep.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Tool +metadata: + name: qa_plugin_sleep + label: + en_US: QA Plugin Sleep + zh_Hans: QA 插件延迟 + description: + en_US: Sleeps for a bounded number of seconds and returns deterministic text. + zh_Hans: 延迟指定秒数后返回确定性文本。 +spec: + parameters: + type: object + properties: + seconds: + type: number + description: Seconds to sleep, clamped to the 0-15 range. + text: + type: string + description: Text to include in the deterministic result. + required: + - seconds + - text + llm_prompt: Sleep for a bounded duration and return qa-plugin-smoke sleep text for steering tests. +execution: + python: + path: qa_plugin_sleep.py + attr: QAPluginSleepTool diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg new file mode 100644 index 0000000..a4a50f8 Binary files /dev/null and b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/dist/qa-plugin-smoke-0.1.0.lbpkg differ diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/main.py b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/main.py new file mode 100644 index 0000000..e2a50d3 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/main.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from langbot_plugin.api.definition.plugin import BasePlugin + + +class PluginSmoke(BasePlugin): + async def initialize(self) -> None: + self.ready_marker = "qa-plugin-smoke-ready" diff --git a/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/manifest.yaml b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/manifest.yaml new file mode 100644 index 0000000..b5a0f55 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/manifest.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: Plugin +metadata: + author: qa + name: plugin-smoke + repository: https://example.invalid/langbot/qa-plugin-smoke + version: 0.1.0 + description: + en_US: Local fixture plugin for LangBot plugin e2e smoke tests. + zh_Hans: LangBot 插件端到端冒烟测试用本地夹具插件。 + label: + en_US: QA Plugin Smoke + zh_Hans: QA 插件冒烟测试 + icon: assets/icon.svg +spec: + config: [] + components: + EventListener: + fromDirs: + - path: components/events/ + maxDepth: 1 + Tool: + fromDirs: + - path: components/tools/ + maxDepth: 1 + Page: + fromDirs: + - path: components/pages/ + maxDepth: 2 +execution: + python: + path: main.py + attr: PluginSmoke diff --git a/skills/skills/langbot-testing/fixtures/rag/parser-golden.html b/skills/skills/langbot-testing/fixtures/rag/parser-golden.html new file mode 100644 index 0000000..a684f93 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/rag/parser-golden.html @@ -0,0 +1,32 @@ + + + + + LangRAG GeneralParsers Golden Fixture + + +
+
+

Parser Golden Case

+

The parser-rag golden sentinel is aurora-parser-rag-9137.

+
+ + + + + + + + + + + + + +
Parser fieldGolden value
parser_pluginGeneralParsers
knowledge_engineLangRAG
+
+

Retrieve tests should return aurora-parser-rag-9137 with GeneralParsers and LangRAG.

+
+
+ + diff --git a/skills/skills/langbot-testing/fixtures/rag/sentinel-doc.txt b/skills/skills/langbot-testing/fixtures/rag/sentinel-doc.txt new file mode 100644 index 0000000..310d6c1 --- /dev/null +++ b/skills/skills/langbot-testing/fixtures/rag/sentinel-doc.txt @@ -0,0 +1,6 @@ +LangBot QA test document. + +The local agent runner retrieval sentinel is azalea-cobalt-7421. +When asked about the sentinel, answer with exactly azalea-cobalt-7421. + +This document is intentionally small so LangRAG ingestion and retrieval can be tested quickly. diff --git a/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs b/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs new file mode 100755 index 0000000..3bf1fac --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function run(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const script = ` +import asyncio +import aiosqlite + +async def main(): + async with aiosqlite.connect(':memory:') as db: + await db.execute('create table t(id integer primary key)') + await db.commit() + print('AIOSQLITE_READY') + +asyncio.run(main()) +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-async-db-readiness"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const timeoutMs = Number(env.LANGBOT_ASYNC_DB_READINESS_TIMEOUT_MS || "5000"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script], cwd: langbotRepo }; + const result = { + source: "automation", + probe: "aiosqlite-readiness", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_path: langbotRepo, + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson }, + evidence_collected: ["filesystem"], + }; + try { + if (!existsSync(langbotRepo)) { + result.status = "env_issue"; + result.reason = `LANGBOT_REPO/default ../LangBot did not resolve: ${langbotRepo}`; + } else { + const proc = await run(command, timeoutMs, { + ...process.env, + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }); + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + result.exit_status = proc.status; + result.signal = proc.signal; + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "env_issue"; + result.reason = `aiosqlite readiness timed out after ${timeoutMs}ms`; + } else if (proc.status === 0 && proc.stdout.includes("AIOSQLITE_READY")) { + result.status = "pass"; + result.reason = "aiosqlite readiness passed"; + } else { + result.status = "env_issue"; + result.reason = `aiosqlite readiness exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "env_issue"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs b/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs new file mode 100755 index 0000000..bc7ded8 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function run(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const script = String.raw` +import asyncio +import json +import sys +from pathlib import Path + +from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor +from langbot.pkg.agent.runner.errors import RunnerExecutionError, RunnerProtocolError +from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer + +class Logger: + def debug(self, *_args, **_kwargs): pass + def info(self, *_args, **_kwargs): pass + def warning(self, *_args, **_kwargs): pass + def error(self, *_args, **_kwargs): pass + +class App: + logger = Logger() + +def descriptor(): + return AgentRunnerDescriptor( + id='plugin:qa/agent-runner/default', + source='plugin', + label={'en_US': 'QA AgentRunner'}, + plugin_author='qa', + plugin_name='agent-runner', + runner_name='default', + capabilities={'streaming': True}, + ) + +async def consume_behavior(normalizer, desc, behavior): + messages = [] + chunks = [] + failures = [] + protocol_errors = [] + for result in behavior['results']: + try: + normalized = await normalizer.normalize(result, desc) + except RunnerExecutionError as exc: + failures.append(str(exc)) + continue + except RunnerProtocolError as exc: + protocol_errors.append(str(exc)) + continue + if normalized is None: + continue + content = getattr(normalized, 'content', '') + if normalized.__class__.__name__ == 'MessageChunk': + chunks.append(content) + else: + messages.append(content) + return { + 'name': behavior['name'], + 'messages': messages, + 'chunks': chunks, + 'failures': failures, + 'protocol_errors': protocol_errors, + } + +async def main(path): + data = json.loads(Path(path).read_text()) + normalizer = AgentResultNormalizer(App()) + desc = descriptor() + observed = [await consume_behavior(normalizer, desc, item) for item in data['behaviors']] + by_name = {item['name']: item for item in observed} + assert by_name['ok']['messages'] == ['QA_RUNNER_OK'], by_name['ok'] + assert ''.join(by_name['stream_ok']['chunks']) == 'QA_RUNNER_STREAM_OK', by_name['stream_ok'] + assert by_name['empty_output']['messages'] == [] and by_name['empty_output']['chunks'] == [], by_name['empty_output'] + assert by_name['malformed_result']['messages'] == [] and by_name['malformed_result']['chunks'] == [], by_name['malformed_result'] + assert by_name['controlled_failure']['failures'], by_name['controlled_failure'] + print('QA_RUNNER_BEHAVIOR_MATRIX_OK behaviors=%d' % len(observed)) + +asyncio.run(main(sys.argv[1])) +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-behavior-matrix"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); + const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const fixturePath = resolve(root, "skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, fixturePath], cwd: langbotRepo }; + const result = { + source: "automation", + probe: "agent-runner-behavior-matrix", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + fixture_path: fixturePath, + repo_path: langbotRepo, + python_paths: [sdkSrc], + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson }, + evidence_collected: ["filesystem"], + }; + try { + if (!existsSync(langbotRepo) || !existsSync(fixturePath)) { + result.status = "env_issue"; + result.reason = `missing repo or fixture: ${langbotRepo} ${fixturePath}`; + } else { + const proc = await run(command, timeoutMs, { + ...process.env, + PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter), + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }); + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + result.exit_status = proc.status; + result.signal = proc.signal; + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `behavior matrix timed out after ${timeoutMs}ms`; + } else if (proc.status === 0 && proc.stdout.includes("QA_RUNNER_BEHAVIOR_MATRIX_OK")) { + result.status = "pass"; + result.reason = "behavior matrix passed"; + } else { + result.status = "fail"; + result.reason = `behavior matrix exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs b/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs new file mode 100755 index 0000000..00c447f --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function run(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const script = String.raw` +import asyncio +import importlib.util +import sys +from pathlib import Path + +from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources +from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext +from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger + +fixture = Path(sys.argv[1]) +runner_py = fixture / "components" / "agent_runner" / "default.py" +manifest = fixture / "manifest.yaml" +runner_yaml = fixture / "components" / "agent_runner" / "default.yaml" +assert manifest.exists(), manifest +assert runner_yaml.exists(), runner_yaml +spec = importlib.util.spec_from_file_location("qa_agent_runner_fixture", runner_py) +module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(module) + +def context(run_id, text): + return AgentRunContext( + run_id=run_id, + trigger=AgentTrigger(type="message.received", source="webui"), + event=AgentEventContext(event_id=f"evt-{run_id}", event_type="message.received", source="webui"), + input=AgentInput(text=text), + delivery=DeliveryContext(surface="debug_chat"), + resources=AgentResources(), + runtime=AgentRuntimeContext(langbot_version="qa"), + ) + +async def collect(text): + runner = module.DefaultAgentRunner() + results = [] + async for result in runner.run(context(f"run-{len(text)}", text)): + results.append(result) + return results + +async def main(): + normal = await collect("hello") + assert len(normal) == 1, normal + assert normal[0].type.value == "run.completed" + assert normal[0].data["message"]["content"] == "QA_AGENT_RUNNER_OK:hello" + + stream = await collect("stream hello") + assert [item.type.value for item in stream] == ["message.delta", "message.delta", "message.delta", "run.completed"] + assert "".join(item.data["chunk"]["content"] for item in stream[:3]) == "QA_AGENT_RUNNER_OK:stream hello" + + failed = await collect("please fail") + assert len(failed) == 1 + assert failed[0].type.value == "run.failed" + assert failed[0].data["error"] == "QA_AGENT_RUNNER_CONTROLLED_FAILURE" + print("QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK") + +asyncio.run(main()) +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-fixture-contract"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk"); + const sdkSrc = resolve(sdkRepo, "src"); + const fixturePath = resolve(root, "skills/langbot-testing/fixtures/plugins/qa-agent-runner"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, fixturePath], cwd: sdkRepo }; + const result = { + source: "automation", + probe: "agent-runner-fixture-contract", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_path: sdkRepo, + fixture_path: fixturePath, + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson }, + evidence_collected: ["filesystem"], + }; + try { + if (!existsSync(sdkRepo) || !existsSync(fixturePath)) { + result.status = "env_issue"; + result.reason = `SDK repo or fixture path missing: ${sdkRepo} ${fixturePath}`; + } else { + const proc = await run(command, timeoutMs, { + ...process.env, + PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter), + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }); + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + result.exit_status = proc.status; + result.signal = proc.signal; + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `fixture contract probe timed out after ${timeoutMs}ms`; + } else if (proc.status === 0 && proc.stdout.includes("QA_AGENT_RUNNER_FIXTURE_CONTRACT_OK")) { + result.status = "pass"; + result.reason = "QA AgentRunner fixture contract passed"; + } else { + result.status = "fail"; + result.reason = `fixture contract exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs new file mode 100755 index 0000000..f6c9838 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +import { runPytestProbe } from "./pytest-probe.mjs"; + +await runPytestProbe({ + caseId: "agent-runner-ledger-concurrency", + repoEnvKey: "LANGBOT_REPO", + defaultRepo: "../LangBot", + pythonPathEnvKeys: ["LANGBOT_PLUGIN_SDK_REPO"], + defaultPythonPaths: ["../langbot-plugin-sdk/src"], + description: "LangBot AgentRunner run ledger claim, lease, authorization, and runtime-admin pytest probe.", + testTargets: [ + "tests/unit_tests/agent/test_run_ledger_store.py::test_create_queued_run_claim_renew_release", + "tests/unit_tests/agent/test_run_ledger_store.py::test_expired_claim_can_be_reclaimed", + "tests/unit_tests/agent/test_run_ledger_api_auth.py::test_runtime_admin_can_register_list_and_claim_with_own_run_session", + "tests/unit_tests/agent/test_run_ledger_api_auth.py::test_run_append_result_basic_path", + "tests/unit_tests/agent/test_run_ledger_api_auth.py::test_run_finalize_basic_path", + "tests/unit_tests/agent/test_run_ledger_api_auth.py::test_run_claim_renew_and_release_actions", + ], +}); diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs new file mode 100755 index 0000000..ec46700 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function run(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const script = String.raw` +import concurrent.futures +import sqlite3 +import sys +import time +from pathlib import Path + +from sqlalchemy import create_engine + +from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime + +db_path = Path(sys.argv[1]) +run_count = 120 +worker_count = 8 +engine = create_engine(f"sqlite:///{db_path}") +for table in (AgentRun.__table__, AgentRunEvent.__table__, AgentRuntime.__table__): + table.create(engine) + +with engine.begin() as conn: + conn.execute(AgentRun.__table__.insert(), [ + { + "run_id": f"run-{i:03d}", + "event_id": f"evt-{i:03d}", + "binding_id": "binding-contention", + "runner_id": "plugin:qa/agent-runner/default", + "status": "queued", + "queue_name": "default", + "priority": run_count - i, + } + for i in range(run_count) + ]) + +def worker(worker_id): + claimed = [] + conn = sqlite3.connect(db_path, timeout=10, isolation_level=None) + conn.execute("pragma busy_timeout=10000") + try: + while True: + try: + conn.execute("begin immediate") + row = conn.execute( + "select run_id from agent_run where status = 'queued' " + "order by priority desc, id asc limit 1" + ).fetchone() + if row is None: + conn.execute("commit") + return claimed + run_id = row[0] + updated = conn.execute( + "update agent_run " + "set status = 'completed', claimed_by_runtime_id = ?, dispatch_attempts = coalesce(dispatch_attempts, 0) + 1 " + "where run_id = ? and status = 'queued'", + (f"worker-{worker_id}", run_id), + ).rowcount + conn.execute("commit") + if updated == 1: + claimed.append(run_id) + except sqlite3.OperationalError as exc: + try: + conn.execute("rollback") + except sqlite3.OperationalError: + pass + if "locked" not in str(exc).lower(): + raise + time.sleep(0.01) + finally: + conn.close() + +with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as pool: + claims = [run_id for worker_claims in pool.map(worker, range(worker_count)) for run_id in worker_claims] + +conn = sqlite3.connect(db_path) +rows = conn.execute( + "select run_id, status, dispatch_attempts, claimed_by_runtime_id from agent_run" +).fetchall() +conn.close() + +assert len(claims) == run_count, len(claims) +assert len(set(claims)) == run_count, "duplicate claims detected" +assert all(row[1] == "completed" for row in rows), rows[:5] +assert all(row[2] == 1 for row in rows), rows[:5] +assert all(row[3] for row in rows), rows[:5] +print(f"LEDGER_CONTENTION_OK runs={run_count} workers={worker_count}") +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-ledger-contention"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); + const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const dbPath = join(evidenceDir, "ledger-contention.sqlite3"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, dbPath], cwd: langbotRepo }; + const result = { + source: "automation", + probe: "agent-runner-ledger-contention", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_path: langbotRepo, + python_paths: [sdkSrc], + database_path: dbPath, + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, database: dbPath, automation_result_json: automationResultJson, result_json: resultJson }, + evidence_collected: ["filesystem"], + }; + try { + if (!existsSync(langbotRepo)) { + result.status = "env_issue"; + result.reason = `LANGBOT_REPO/default ../LangBot did not resolve: ${langbotRepo}`; + } else { + const proc = await run(command, timeoutMs, { + ...process.env, + PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter), + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }); + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + result.exit_status = proc.status; + result.signal = proc.signal; + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `ledger contention timed out after ${timeoutMs}ms`; + } else if (proc.status === 0 && proc.stdout.includes("LEDGER_CONTENTION_OK")) { + result.status = "pass"; + result.reason = "ledger contention probe passed"; + } else { + result.status = "fail"; + result.reason = `ledger contention exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs new file mode 100755 index 0000000..1b5416d --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function resolveFromRoot(root, value) { + return resolve(root, value); +} + +function runProcess(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const probeScript = String.raw` +import sqlite3 +from sqlalchemy import create_engine, inspect + +from langbot.pkg.agent.runner.run_ledger_store import TERMINAL_STATUSES +from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime + +expected_statuses = {'created', 'queued', 'claimed', 'running', 'completed', 'failed', 'cancelled', 'timeout'} +expected_terminal = {'completed', 'failed', 'cancelled', 'timeout'} +assert TERMINAL_STATUSES == expected_terminal, TERMINAL_STATUSES + +engine = create_engine('sqlite:///:memory:') +for table in (AgentRun.__table__, AgentRunEvent.__table__, AgentRuntime.__table__): + table.create(engine) + +inspector = inspect(engine) +assert set(inspector.get_table_names()) == {'agent_run', 'agent_run_event', 'agent_runtime'} +agent_run_indexes = {index['name']: tuple(index['column_names']) for index in inspector.get_indexes('agent_run')} +for name in ( + 'ix_agent_run_scope_status', + 'ix_agent_run_runner_status', + 'ix_agent_run_queue_claim', + 'ix_agent_run_run_id', + 'ix_agent_run_claim_token', +): + assert name in agent_run_indexes, agent_run_indexes + +event_uniques = { + unique['name']: tuple(unique['column_names']) + for unique in inspector.get_unique_constraints('agent_run_event') +} +assert event_uniques['uq_agent_run_event_run_sequence'] == ('run_id', 'sequence') + +with engine.begin() as conn: + conn.execute(AgentRun.__table__.insert().values( + run_id='run-sync', + event_id='evt-sync', + binding_id='binding-sync', + runner_id='plugin:test/runner/default', + status='queued', + queue_name='default', + priority=10, + )) + row = conn.execute(AgentRun.__table__.select().where(AgentRun.__table__.c.run_id == 'run-sync')).mappings().one() + assert row['status'] == 'queued' + conn.execute(AgentRunEvent.__table__.insert().values( + run_id='run-sync', + sequence=1, + type='message.completed', + data_json='{}', + source='runner', + )) + +print('LEDGER_INVARIANTS_OK tables=3 statuses=8') +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-ledger-invariants"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const langbotRepo = resolveFromRoot(root, env.LANGBOT_REPO || "../LangBot"); + const sdkSrc = resolveFromRoot(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", probeScript], cwd: langbotRepo }; + const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); + const result = { + source: "automation", + probe: "python-sync", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_path: langbotRepo, + python_paths: [sdkSrc], + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { + stdout_log: stdoutLog, + stderr_log: stderrLog, + automation_result_json: automationResultJson, + result_json: resultJson, + }, + evidence_collected: ["filesystem"], + }; + + try { + if (!existsSync(langbotRepo)) { + result.status = "env_issue"; + result.reason = `LANGBOT_REPO/default ../LangBot did not resolve: ${langbotRepo}`; + } else { + const childEnv = { + ...process.env, + PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter), + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }; + await mkdir(childEnv.UV_CACHE_DIR, { recursive: true }); + const proc = await runProcess(command, timeoutMs, childEnv); + result.exit_status = proc.status; + result.signal = proc.signal; + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `ledger invariant probe timed out after ${timeoutMs}ms`; + } else if (proc.status === 0) { + result.status = "pass"; + result.reason = "ledger invariant probe passed"; + } else { + result.status = "fail"; + result.reason = `ledger invariant probe exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs new file mode 100755 index 0000000..3575358 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function run(command, timeoutMs, childEnv) { + return new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + detached: true, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +const script = String.raw` +from sqlalchemy import create_engine, select, update + +from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime + +run_count = 100 +runtime_count = 5 +engine = create_engine('sqlite:///:memory:') +for table in (AgentRun.__table__, AgentRunEvent.__table__, AgentRuntime.__table__): + table.create(engine) + +claimed = [] +with engine.begin() as conn: + conn.execute(AgentRun.__table__.insert(), [ + { + 'run_id': f'run-{i:03d}', + 'event_id': f'evt-{i:03d}', + 'binding_id': 'binding-stress', + 'runner_id': 'plugin:qa/agent-runner/default', + 'status': 'queued', + 'queue_name': 'default', + 'priority': run_count - i, + } + for i in range(run_count) + ]) + while True: + row = conn.execute( + select(AgentRun.__table__) + .where(AgentRun.__table__.c.status == 'queued') + .order_by(AgentRun.__table__.c.priority.desc(), AgentRun.__table__.c.id.asc()) + .limit(1) + ).mappings().first() + if row is None: + break + runtime_id = f'runtime-{len(claimed) % runtime_count}' + conn.execute( + update(AgentRun.__table__) + .where(AgentRun.__table__.c.run_id == row['run_id']) + .values(status='completed', claimed_by_runtime_id=runtime_id, dispatch_attempts=1) + ) + claimed.append(row['run_id']) + rows = conn.execute(select(AgentRun.__table__)).mappings().all() + +assert len(claimed) == run_count +assert len(set(claimed)) == run_count +assert all(row['status'] == 'completed' for row in rows) +assert all(row['dispatch_attempts'] == 1 for row in rows) +assert claimed[0] == 'run-000' +assert claimed[-1] == 'run-099' +print(f'LEDGER_STRESS_OK runs={run_count} runtimes={runtime_count}') +`; + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "agent-runner-ledger-stress"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const startedAt = new Date(); + const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); + const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const stdoutLog = join(evidenceDir, "probe-stdout.log"); + const stderrLog = join(evidenceDir, "probe-stderr.log"); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const resultJson = join(evidenceDir, "result.json"); + const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); + const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script], cwd: langbotRepo }; + const result = { + source: "automation", + probe: "agent-runner-ledger-stress", + case_id: caseId, + run_id: runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_path: langbotRepo, + python_paths: [sdkSrc], + command, + timeout_ms: timeoutMs, + exit_status: null, + signal: null, + evidence: { stdout_log: stdoutLog, stderr_log: stderrLog, automation_result_json: automationResultJson, result_json: resultJson }, + evidence_collected: ["filesystem"], + }; + try { + if (!existsSync(langbotRepo)) { + result.status = "env_issue"; + result.reason = `LANGBOT_REPO/default ../LangBot did not resolve: ${langbotRepo}`; + } else { + const proc = await run(command, timeoutMs, { + ...process.env, + PYTHONPATH: [sdkSrc, process.env.PYTHONPATH].filter(Boolean).join(delimiter), + UV_CACHE_DIR: env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"), + }); + await writeFile(stdoutLog, proc.stdout, "utf8"); + await writeFile(stderrLog, proc.stderr, "utf8"); + result.exit_status = proc.status; + result.signal = proc.signal; + if (proc.error) { + result.status = "env_issue"; + result.reason = proc.error.message; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `ledger stress timed out after ${timeoutMs}ms`; + } else if (proc.status === 0 && proc.stdout.includes("LEDGER_STRESS_OK")) { + result.status = "pass"; + result.reason = "ledger stress probe passed"; + } else { + result.status = "fail"; + result.reason = `ledger stress exited with status ${proc.status}`; + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + process.exit(result.status === "pass" ? 0 : result.status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs b/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs new file mode 100755 index 0000000..07ee5f9 --- /dev/null +++ b/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { runPytestProbe } from "./pytest-probe.mjs"; + +await runPytestProbe({ + caseId: "agent-runner-runtime-chaos", + repoEnvKey: "LANGBOT_PLUGIN_SDK_REPO", + defaultRepo: "../langbot-plugin-sdk", + description: "LangBot plugin SDK AgentRunner runtime failure, timeout, forwarding, and pull API pytest probe.", + testTargets: [ + "tests/runtime/plugin/test_mgr_agent_runner.py", + "tests/runtime/test_pull_api_handlers.py", + ], +}); diff --git a/skills/skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs b/skills/skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs new file mode 100755 index 0000000..af5153d --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-debug-chat-concurrency.mjs @@ -0,0 +1,837 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import net from "node:net"; +import tls from "node:tls"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; +import { + apiJson, + appendLine, + ensureEvidence, + evidencePaths, + loadEnvFiles, + localIsoWithOffset, + redact, + resetAndAuthLocalUser, + writeResult, +} from "../../../scripts/e2e/lib/langbot-e2e.mjs"; +import { + buildProviderTimingMetrics, + summarizeFakeProviderState, +} from "./lib/fake-provider-timing.mjs"; + +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; + +await loadEnvFiles(); +const caseId = env.LBS_CASE_ID || "langbot-debug-chat-concurrency"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const metricsPath = resolve(paths.evidenceDir, "metrics.json"); +const samplesPath = resolve(paths.evidenceDir, "samples.json"); +const fakeProviderStatePath = resolve(paths.evidenceDir, "fake-provider-state.json"); +const resetDiagnosticPath = resolve(paths.evidenceDir, "debug-chat-reset-diagnostic.json"); +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const fakeProviderUrl = env.LANGBOT_FAKE_PROVIDER_URL || ""; +const pipelineUrl = env.LANGBOT_E2E_PIPELINE_URL || env.LANGBOT_PIPELINE_URL || ""; +const pipelineName = env.LANGBOT_E2E_PIPELINE_NAME || env.LANGBOT_PIPELINE_NAME || ""; +const sessionType = env.LANGBOT_DEBUG_CHAT_LOAD_SESSION_TYPE || env.LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE || "person"; +const totalRequests = positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_REQUESTS, defaultRequests(caseId)); +const concurrency = Math.min(totalRequests, positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_CONCURRENCY, defaultConcurrency(caseId))); +const timeoutMs = positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_TIMEOUT_MS, defaultTimeout(caseId)); +const expectedPrefix = env.LANGBOT_DEBUG_CHAT_LOAD_EXPECTED_PREFIX || "LBQA"; +const promptTemplate = env.LANGBOT_DEBUG_CHAT_LOAD_PROMPT_TEMPLATE + || "请只回复 \"{expected}\",不要解释,不要添加其他字符。"; +const stream = bool(env.LANGBOT_DEBUG_CHAT_LOAD_STREAM, true); +const resetBeforeRun = bool(env.LANGBOT_DEBUG_CHAT_LOAD_RESET, true); +const responseP95BudgetMs = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_RESPONSE_P95_MS, defaultP95Budget(caseId)); +const firstResponseP95BudgetMs = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_FIRST_RESPONSE_P95_MS, 0); +const maxErrorRate = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_MAX_ERROR_RATE, 0); +const minErrorRate = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_MIN_ERROR_RATE, 0); +const minErrorCount = nonNegativeInteger(env.LANGBOT_DEBUG_CHAT_LOAD_MIN_ERROR_COUNT, 0); +const minOkCount = nonNegativeInteger(env.LANGBOT_DEBUG_CHAT_LOAD_MIN_OK_COUNT, 0); +const minProviderFaultCount = nonNegativeInteger(env.LANGBOT_DEBUG_CHAT_LOAD_MIN_PROVIDER_FAULT_COUNT, 0); +const failOnFinalMismatch = bool(env.LANGBOT_DEBUG_CHAT_LOAD_FAIL_ON_FINAL_MISMATCH, false); +const failureSignals = textList(env.LANGBOT_E2E_FAILURE_SIGNALS || env.LANGBOT_DEBUG_CHAT_LOAD_FAILURE_SIGNALS || ""); + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + backend_url: backendUrl, + pipeline_url: pipelineUrl, + pipeline_name: pipelineName, + pipeline_id: "", + session_type: sessionType, + load_profile: { + requests: totalRequests, + concurrency, + timeout_ms: timeoutMs, + stream, + reset_before_run: resetBeforeRun, + fail_on_final_mismatch: failOnFinalMismatch, + }, + evidence: { + network_log: paths.networkLog, + metrics_json: metricsPath, + samples_json: samplesPath, + fake_provider_state_json: fakeProviderStatePath, + debug_chat_reset_diagnostic_json: resetDiagnosticPath, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["metrics", "network", "api_diagnostic", "filesystem"], +}; + +try { + if (!backendUrl) { + result.status = "env_issue"; + throw new Error("LANGBOT_BACKEND_URL is not configured."); + } + if (!["person", "group"].includes(sessionType)) { + throw new Error(`LANGBOT_DEBUG_CHAT_LOAD_SESSION_TYPE must be person or group, got ${sessionType}.`); + } + const backendReady = await backendReachable(backendUrl); + if (!backendReady) { + result.status = "env_issue"; + throw new Error(`Backend did not respond at ${backendUrl}.`); + } + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + result.status = "env_issue"; + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this probe can resolve/reset the Debug Chat session."); + } + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + + const pipeline = await resolvePipeline({ backendUrl, token: auth.token, pipelineUrl, pipelineName }); + result.pipeline_id = pipeline.id; + result.pipeline_name = pipeline.name || pipelineName; + if (!result.pipeline_url && env.LANGBOT_FRONTEND_URL) { + result.pipeline_url = `${env.LANGBOT_FRONTEND_URL.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(pipeline.id)}`; + } + + if (resetBeforeRun) { + const reset = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.id)}/ws/reset/${encodeURIComponent(sessionType)}`, { + method: "POST", + token: auth.token, + }); + const resetDiagnostic = { + status: isApiFailure(reset) ? "fail" : "ready", + http_status: reset.status, + code: reset.json.code ?? null, + reason: isApiFailure(reset) ? reset.json.msg || "Debug Chat reset failed." : "Debug Chat session reset.", + }; + await writeFile(resetDiagnosticPath, `${JSON.stringify(resetDiagnostic, null, 2)}\n`, "utf8"); + if (resetDiagnostic.status === "fail") { + throw new Error(resetDiagnostic.reason); + } + } + + const wsUrl = websocketUrl(backendUrl, pipeline.id, sessionType); + const loadStartedAt = performance.now(); + const samples = await runLoad({ + wsUrl, + totalRequests, + concurrency, + timeoutMs, + promptTemplate, + expectedPrefix, + stream, + failOnFinalMismatch, + failureSignals, + }); + const loadDurationMs = performance.now() - loadStartedAt; + const fakeProviderState = await readFakeProviderState(fakeProviderUrl); + if (fakeProviderState) { + await writeFile(fakeProviderStatePath, `${JSON.stringify(fakeProviderState, null, 2)}\n`, "utf8"); + } + const metrics = buildMetrics({ + samples, + totalRequests, + concurrency, + timeoutMs, + loadDurationMs, + backendUrl, + pipelineId: pipeline.id, + sessionType, + fakeProviderState, + }); + const thresholds = buildThresholds(metrics); + const passed = Object.values(thresholds).every((item) => item.pass); + result.status = passed ? "pass" : "fail"; + result.reason = passed + ? "Debug Chat WebSocket concurrency probe passed all thresholds." + : "Debug Chat WebSocket concurrency probe breached latency or error-rate thresholds."; + result.metrics_summary = { + requests: metrics.total_requests, + concurrency: metrics.concurrency, + ok_count: metrics.ok_count, + error_count: metrics.error_count, + timeout_count: metrics.timeout_count, + error_rate: metrics.error_rate, + response_p50_ms: metrics.response_duration_ms.p50, + response_p95_ms: metrics.response_duration_ms.p95, + first_assistant_event_p95_ms: metrics.first_assistant_event_ms.p95, + first_assistant_content_p95_ms: metrics.first_assistant_content_ms.p95, + first_response_p95_ms: metrics.first_response_ms.p95, + throughput_rps: metrics.throughput_rps, + status_counts: metrics.status_counts, + fake_provider_request_count: metrics.fake_provider?.request_count ?? null, + fake_provider_fault_count: metrics.fake_provider?.fault_count ?? null, + fake_provider_duration_p95_ms: metrics.provider_timing?.provider_duration_ms.p95 ?? null, + langbot_overhead_estimate_p95_ms: metrics.provider_timing?.langbot_overhead_estimate_ms.p95 ?? null, + send_to_provider_start_p95_ms: metrics.provider_timing?.send_to_provider_start_ms.p95 ?? null, + provider_finish_to_ws_final_p95_ms: metrics.provider_timing?.provider_finish_to_ws_final_ms.p95 ?? null, + provider_timing_matched_request_count: metrics.provider_timing?.matched_request_count ?? null, + }; + result.thresholds_summary = thresholds; + result.artifacts = { + metrics_json: metricsPath, + samples_json: samplesPath, + fake_provider_state_json: fakeProviderState ? fakeProviderStatePath : "", + network_log: paths.networkLog, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }; + + await writeFile(metricsPath, `${JSON.stringify({ ...metrics, thresholds }, null, 2)}\n`, "utf8"); + await writeFile(samplesPath, `${JSON.stringify(samples, null, 2)}\n`, "utf8"); +} catch (error) { + if (!["env_issue", "blocked"].includes(result.status)) { + result.status = looksLikeEnvIssue(error) ? "env_issue" : "fail"; + } + result.reason = result.reason || safeReason(error.message); +} finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + await mkdir(paths.evidenceDir, { recursive: true }); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +exit(result.status === "pass" ? 0 : result.status === "env_issue" || result.status === "blocked" ? 2 : 1); + +function defaultRequests(id) { + return id.includes("space") ? 3 : 12; +} + +function defaultConcurrency(id) { + return id.includes("space") ? 1 : 4; +} + +function defaultTimeout(id) { + return id.includes("space") ? 120_000 : 30_000; +} + +function defaultP95Budget(id) { + return id.includes("space") ? 120_000 : 5_000; +} + +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(String(value || ""), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function nonNegativeInteger(value, fallback) { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + +function positiveNumber(value, fallback) { + const parsed = Number(value || ""); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function bool(value, fallback) { + if (value === undefined || value === "") return fallback; + if (/^(1|true|yes|on)$/i.test(String(value))) return true; + if (/^(0|false|no|off)$/i.test(String(value))) return false; + return fallback; +} + +function textList(value) { + return String(value || "") + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); +} + +async function backendReachable(baseUrl) { + try { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/healthz`, { + signal: AbortSignal.timeout(3000), + }); + return response.status < 500; + } catch { + return false; + } +} + +async function readFakeProviderState(rootUrl) { + if (!rootUrl) return null; + try { + const response = await fetch(`${normalizeProviderRootUrl(rootUrl)}/__qa/config`, { + signal: AbortSignal.timeout(3000), + }); + const json = await response.json().catch(() => ({})); + return { + status: response.ok && json.ok === true ? "loaded" : "unavailable", + url: normalizeProviderRootUrl(rootUrl), + http_status: response.status, + model: json.model || "", + config: json.config || {}, + request_count: Number.isFinite(json.request_count) ? json.request_count : null, + recent_requests: Array.isArray(json.recent_requests) ? json.recent_requests : [], + }; + } catch (error) { + return { + status: "unavailable", + url: normalizeProviderRootUrl(rootUrl), + reason: safeReason(error.message), + request_count: null, + recent_requests: [], + }; + } +} + +function normalizeProviderRootUrl(value) { + const trimmed = String(value || "").trim().replace(/\/$/, ""); + return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; +} + +function pipelineIdFromUrl(url) { + if (!url) return ""; + try { + const parsed = new URL(url); + return parsed.searchParams.get("id") || ""; + } catch { + return ""; + } +} + +async function resolvePipeline({ backendUrl, token, pipelineUrl, pipelineName }) { + const idFromUrl = pipelineIdFromUrl(pipelineUrl); + if (idFromUrl) { + const response = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(idFromUrl)}`, { token }); + const pipeline = response.json.data?.pipeline; + if (isApiFailure(response) || !pipeline?.uuid) { + throw new Error(response.json.msg || `Could not load pipeline ${idFromUrl}.`); + } + return { id: pipeline.uuid, name: pipeline.name || "" }; + } + if (!pipelineName) { + throw new Error("Set LANGBOT_E2E_PIPELINE_URL or LANGBOT_E2E_PIPELINE_NAME before running this probe."); + } + const response = await apiJson(backendUrl, "/api/v1/pipelines", { token }); + if (isApiFailure(response)) { + throw new Error(response.json.msg || "Failed to list pipelines."); + } + const pipeline = (response.json.data?.pipelines || []).find((item) => item.name === pipelineName); + if (!pipeline?.uuid) { + throw new Error(`Could not find pipeline named ${pipelineName}.`); + } + return { id: pipeline.uuid, name: pipeline.name || pipelineName }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json.code !== undefined && response.json.code !== 0); +} + +function websocketUrl(baseUrl, pipelineId, sessionType) { + const parsed = new URL(baseUrl); + parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:"; + parsed.pathname = `/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/connect`; + parsed.search = `?session_type=${encodeURIComponent(sessionType)}`; + return parsed.toString(); +} + +async function runLoad(options) { + const samples = []; + let nextIndex = 0; + const workers = Array.from({ length: options.concurrency }, async () => { + while (nextIndex < options.totalRequests) { + const index = nextIndex; + nextIndex += 1; + const sample = await runSingleRequest({ ...options, index }); + samples.push(sample); + } + }); + await Promise.all(workers); + return samples.sort((left, right) => left.index - right.index); +} + +function expectedForIndex(prefix, index) { + return `${prefix}-${String(index + 1).padStart(4, "0")}`; +} + +function promptForIndex(template, expected) { + return template.replaceAll("{expected}", expected); +} + +function runSingleRequest({ + wsUrl, + index, + timeoutMs, + promptTemplate, + expectedPrefix, + stream, + failOnFinalMismatch, + failureSignals, +}) { + return new Promise((resolve) => { + const expected = expectedForIndex(expectedPrefix, index); + const prompt = promptForIndex(promptTemplate, expected); + const sample = { + index, + status: "running", + ok: false, + expected_text: expected, + prompt, + response_text: "", + started_at: new Date().toISOString(), + started_epoch_ms: Date.now(), + connected_at: null, + connected_epoch_ms: null, + sent_at: null, + sent_epoch_ms: null, + first_assistant_event_at: null, + first_assistant_event_epoch_ms: null, + first_assistant_event_ms: null, + first_assistant_content_at: null, + first_assistant_content_epoch_ms: null, + first_assistant_content_ms: null, + first_response_at: null, + first_response_epoch_ms: null, + connected_ms: null, + first_response_ms: null, + response_duration_ms: null, + finished_at: null, + finished_epoch_ms: null, + event_count: 0, + foreign_response_count: 0, + last_foreign_response_text: "", + error: "", + close_code: null, + close_reason: "", + }; + let closed = false; + let connectedAt = 0; + let sentAt = 0; + const startedAt = performance.now(); + let client = null; + const timer = setTimeout(() => { + finish("timeout", `Timed out after ${timeoutMs} ms.`); + }, timeoutMs); + + client = openRawWebSocket(wsUrl, { + onOpen() { + connectedAt = performance.now(); + const now = Date.now(); + sample.connected_at = new Date(now).toISOString(); + sample.connected_epoch_ms = now; + sample.connected_ms = rounded(connectedAt - startedAt); + }, + onMessage(text) { + sample.event_count += 1; + let data; + try { + data = JSON.parse(String(text || "")); + } catch (error) { + finish("error", `Invalid WebSocket JSON: ${error.message}`); + return; + } + appendLine(paths.networkLog, JSON.stringify({ + request_index: index, + type: data.type, + session_type: data.session_type || "", + role: data.data?.role || "", + is_final: data.data?.is_final ?? null, + content_preview: redact(String(data.data?.content || data.message || "").slice(0, 200)), + })).catch(() => {}); + + if (data.type === "connected") { + sentAt = performance.now(); + const now = Date.now(); + sample.sent_at = new Date(now).toISOString(); + sample.sent_epoch_ms = now; + client.send(JSON.stringify({ + type: "message", + message: [{ type: "Plain", text: prompt }], + stream, + })); + return; + } + if (data.type === "error") { + finish("error", data.message || "WebSocket error message."); + return; + } + if (data.type !== "response" || data.data?.role !== "assistant") return; + + const content = String(data.data.content || ""); + markFirstAssistantEvent(sample, sentAt); + if (content) sample.response_text = content; + if (content) markFirstAssistantContent(sample, sentAt); + if (content.includes(expected) && sample.first_response_ms === null && sentAt > 0) { + const now = Date.now(); + sample.first_response_at = new Date(now).toISOString(); + sample.first_response_epoch_ms = now; + sample.first_response_ms = rounded(performance.now() - sentAt); + } + if (data.data.is_final === true) { + const ok = sample.response_text.includes(expected); + if (ok) { + if (sample.first_response_ms === null && sentAt > 0) { + sample.first_response_ms = rounded(performance.now() - sentAt); + } + finish("pass", ""); + } else if (matchesFailureSignal(sample.response_text, failureSignals)) { + finish("app_error", `Assistant final response matched a failure signal: ${sample.response_text}`); + } else if (failOnFinalMismatch && !containsLoadToken(sample.response_text, expectedPrefix)) { + finish("mismatch", `Final assistant response did not include ${expected}: ${sample.response_text}`); + } else { + sample.foreign_response_count += 1; + sample.last_foreign_response_text = sample.response_text; + } + } + }, + onError(error) { + finish("connection_error", `WebSocket connection error: ${error.message}`); + }, + onClose(event) { + sample.close_code = event.code; + sample.close_reason = event.reason || ""; + if (!closed) finish("closed", `WebSocket closed before final assistant response: ${event.code}`); + }, + }); + + function finish(status, reason) { + if (closed) return; + closed = true; + clearTimeout(timer); + sample.status = status; + sample.ok = status === "pass"; + sample.error = status === "timeout" && sample.foreign_response_count > 0 + ? `${reason || ""} Saw ${sample.foreign_response_count} foreign assistant response(s); last=${sample.last_foreign_response_text}` + : reason || ""; + if (sentAt > 0) sample.response_duration_ms = rounded(performance.now() - sentAt); + else sample.response_duration_ms = rounded(performance.now() - startedAt); + const now = Date.now(); + sample.finished_at = new Date(now).toISOString(); + sample.finished_epoch_ms = now; + try { + client?.close(); + } catch { + // Closing a failed socket should not hide the sample result. + } + resolve(sample); + } + }); +} + +function markFirstAssistantEvent(sample, sentAt) { + if (sample.first_assistant_event_ms !== null || sentAt <= 0) return; + const now = Date.now(); + sample.first_assistant_event_at = new Date(now).toISOString(); + sample.first_assistant_event_epoch_ms = now; + sample.first_assistant_event_ms = rounded(performance.now() - sentAt); +} + +function markFirstAssistantContent(sample, sentAt) { + if (sample.first_assistant_content_ms !== null || sentAt <= 0) return; + const now = Date.now(); + sample.first_assistant_content_at = new Date(now).toISOString(); + sample.first_assistant_content_epoch_ms = now; + sample.first_assistant_content_ms = rounded(performance.now() - sentAt); +} + +function containsLoadToken(text, prefix) { + const escaped = String(prefix).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}-\\d{4}`).test(String(text || "")); +} + +function matchesFailureSignal(text, signals) { + const lower = String(text || "").toLowerCase(); + return signals.some((signal) => lower.includes(signal.toLowerCase())); +} + +function openRawWebSocket(wsUrl, handlers) { + const parsed = new URL(wsUrl); + const secure = parsed.protocol === "wss:"; + const port = Number(parsed.port || (secure ? 443 : 80)); + const host = parsed.hostname; + const path = `${parsed.pathname}${parsed.search}`; + const key = crypto.randomBytes(16).toString("base64"); + const socket = secure + ? tls.connect({ host, port, servername: host }) + : net.connect({ host, port }); + let opened = false; + let closed = false; + let buffer = Buffer.alloc(0); + + socket.setNoDelay(true); + socket.on("connect", () => { + const originProtocol = secure ? "https" : "http"; + const request = [ + `GET ${path} HTTP/1.1`, + `Host: ${parsed.host}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13", + `Origin: ${originProtocol}://${parsed.host}`, + "", + "", + ].join("\r\n"); + socket.write(request); + }); + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!opened) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const headerText = buffer.slice(0, headerEnd).toString("utf8"); + buffer = buffer.slice(headerEnd + 4); + if (!/^HTTP\/1\.1 101\b/i.test(headerText)) { + handlers.onError(new Error(`Handshake failed: ${headerText.split("\r\n")[0] || "missing status"}`)); + socket.destroy(); + return; + } + opened = true; + handlers.onOpen(); + } + processFrames(); + }); + socket.on("error", (error) => { + if (!closed) handlers.onError(error); + }); + socket.on("close", () => { + if (closed) return; + closed = true; + handlers.onClose({ code: null, reason: "" }); + }); + + function processFrames() { + while (true) { + const frame = readFrame(buffer); + if (!frame) return; + buffer = buffer.slice(frame.consumed); + if (frame.opcode === 0x1) { + handlers.onMessage(frame.payload.toString("utf8")); + } else if (frame.opcode === 0x8) { + const code = frame.payload.length >= 2 ? frame.payload.readUInt16BE(0) : null; + const reason = frame.payload.length > 2 ? frame.payload.slice(2).toString("utf8") : ""; + closed = true; + handlers.onClose({ code, reason }); + socket.end(); + return; + } else if (frame.opcode === 0x9) { + writeFrame(socket, 0xA, frame.payload); + } + } + } + + return { + send(text) { + if (closed || !opened) return; + writeFrame(socket, 0x1, Buffer.from(text, "utf8")); + }, + close() { + if (closed) return; + closed = true; + if (!socket.destroyed) { + if (opened) writeFrame(socket, 0x8, Buffer.alloc(0)); + setTimeout(() => socket.end(), 50).unref(); + } + }, + }; +} + +function readFrame(buffer) { + if (buffer.length < 2) return null; + const first = buffer[0]; + const second = buffer[1]; + const opcode = first & 0x0f; + const masked = Boolean(second & 0x80); + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + if (buffer.length < offset + 2) return null; + length = buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + if (buffer.length < offset + 8) return null; + const high = buffer.readUInt32BE(offset); + const low = buffer.readUInt32BE(offset + 4); + length = high * 2 ** 32 + low; + offset += 8; + } + let mask = null; + if (masked) { + if (buffer.length < offset + 4) return null; + mask = buffer.slice(offset, offset + 4); + offset += 4; + } + if (buffer.length < offset + length) return null; + let payload = buffer.slice(offset, offset + length); + if (mask) { + payload = Buffer.from(payload); + for (let index = 0; index < payload.length; index += 1) { + payload[index] ^= mask[index % 4]; + } + } + return { + opcode, + payload, + consumed: offset + length, + }; +} + +function writeFrame(socket, opcode, payload) { + const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload || ""); + const mask = crypto.randomBytes(4); + const headerLength = body.length < 126 ? 2 : body.length <= 0xffff ? 4 : 10; + const header = Buffer.alloc(headerLength); + header[0] = 0x80 | opcode; + if (body.length < 126) { + header[1] = 0x80 | body.length; + } else if (body.length <= 0xffff) { + header[1] = 0x80 | 126; + header.writeUInt16BE(body.length, 2); + } else { + header[1] = 0x80 | 127; + header.writeUInt32BE(Math.floor(body.length / 2 ** 32), 2); + header.writeUInt32BE(body.length >>> 0, 6); + } + const masked = Buffer.from(body); + for (let index = 0; index < masked.length; index += 1) { + masked[index] ^= mask[index % 4]; + } + socket.write(Buffer.concat([header, mask, masked])); +} + +function rounded(value) { + return Number(value.toFixed(3)); +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return rounded(sorted[index]); +} + +function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: rounded(Math.min(...values)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: rounded(Math.max(...values)), + }; +} + +function buildMetrics({ samples, totalRequests, concurrency, timeoutMs, loadDurationMs, backendUrl, pipelineId, sessionType, fakeProviderState }) { + const okSamples = samples.filter((sample) => sample.ok); + const statusCounts = {}; + for (const sample of samples) { + statusCounts[sample.status] = (statusCounts[sample.status] || 0) + 1; + } + const errorCount = samples.length - okSamples.length; + return { + probe: caseId, + backend_url: backendUrl, + pipeline_id: pipelineId, + session_type: sessionType, + total_requests: totalRequests, + completed_requests: samples.length, + concurrency, + timeout_ms: timeoutMs, + ok_count: okSamples.length, + error_count: errorCount, + timeout_count: samples.filter((sample) => sample.status === "timeout").length, + error_rate: samples.length === 0 ? 1 : rounded(errorCount / samples.length), + load_duration_ms: rounded(loadDurationMs), + throughput_rps: loadDurationMs <= 0 ? 0 : rounded(okSamples.length / (loadDurationMs / 1000)), + status_counts: statusCounts, + connected_ms: stats(samples.map((sample) => sample.connected_ms).filter(Number.isFinite)), + first_assistant_event_ms: stats(samples.map((sample) => sample.first_assistant_event_ms).filter(Number.isFinite)), + first_assistant_content_ms: stats(samples.map((sample) => sample.first_assistant_content_ms).filter(Number.isFinite)), + first_response_ms: stats(okSamples.map((sample) => sample.first_response_ms).filter(Number.isFinite)), + response_duration_ms: stats(okSamples.map((sample) => sample.response_duration_ms).filter(Number.isFinite)), + fake_provider: summarizeFakeProviderState(fakeProviderState), + provider_timing: buildProviderTimingMetrics(samples, fakeProviderState), + samples, + }; +} + +function buildThresholds(metrics) { + const thresholds = { + error_rate: { actual: metrics.error_rate, max: maxErrorRate, pass: metrics.error_rate <= maxErrorRate }, + response_p95_ms: { + actual: metrics.response_duration_ms.p95, + max: responseP95BudgetMs, + pass: metrics.ok_count > 0 && metrics.response_duration_ms.p95 <= responseP95BudgetMs, + }, + }; + if (minErrorRate > 0) { + thresholds.error_rate_min = { + actual: metrics.error_rate, + min: minErrorRate, + pass: metrics.error_rate >= minErrorRate, + }; + } + if (minErrorCount > 0) { + thresholds.error_count_min = { + actual: metrics.error_count, + min: minErrorCount, + pass: metrics.error_count >= minErrorCount, + }; + } + if (minOkCount > 0) { + thresholds.ok_count_min = { + actual: metrics.ok_count, + min: minOkCount, + pass: metrics.ok_count >= minOkCount, + }; + } + if (minProviderFaultCount > 0) { + const actual = metrics.fake_provider?.fault_count ?? 0; + thresholds.fake_provider_fault_count_min = { + actual, + min: minProviderFaultCount, + pass: actual >= minProviderFaultCount, + }; + } + if (firstResponseP95BudgetMs > 0) { + thresholds.first_response_p95_ms = { + actual: metrics.first_response_ms.p95, + max: firstResponseP95BudgetMs, + pass: metrics.ok_count > 0 && metrics.first_response_ms.p95 <= firstResponseP95BudgetMs, + }; + } + return thresholds; +} + +function looksLikeEnvIssue(error) { + const message = String(error?.message || error || ""); + return /fetch failed|ECONNREFUSED|ENOTFOUND|LANGBOT_.*not configured|Could not read recovery_key|Backend did not respond/i.test(message); +} + +function safeReason(value) { + return redact(String(value || "")).slice(0, 1000); +} diff --git a/skills/skills/langbot-testing/probes/langbot-debug-chat-cross-pipeline-isolation.mjs b/skills/skills/langbot-testing/probes/langbot-debug-chat-cross-pipeline-isolation.mjs new file mode 100755 index 0000000..b83f616 --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-debug-chat-cross-pipeline-isolation.mjs @@ -0,0 +1,861 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import net from "node:net"; +import tls from "node:tls"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { env, exit } from "node:process"; +import { + apiJson, + appendLine, + ensureEvidence, + evidencePaths, + loadEnvFiles, + localIsoWithOffset, + redact, + resetAndAuthLocalUser, + writeResult, +} from "../../../scripts/e2e/lib/langbot-e2e.mjs"; +import { + buildProviderTimingMetrics, + summarizeFakeProviderState, +} from "./lib/fake-provider-timing.mjs"; + +const DEFAULT_LOCAL_PASSWORD = "LangBotE2ELocalPass!2026"; + +await loadEnvFiles(); +const caseId = env.LBS_CASE_ID || "langbot-debug-chat-cross-pipeline-isolation"; +const paths = evidencePaths(caseId); +await ensureEvidence(paths); + +const startedAt = new Date(); +const metricsPath = resolve(paths.evidenceDir, "metrics.json"); +const samplesPath = resolve(paths.evidenceDir, "samples.json"); +const fakeProviderStatePath = resolve(paths.evidenceDir, "fake-provider-state.json"); +const resetDiagnosticPath = resolve(paths.evidenceDir, "debug-chat-reset-diagnostic.json"); +const backendUrl = env.LANGBOT_BACKEND_URL || ""; +const fakeProviderUrl = env.LANGBOT_FAKE_PROVIDER_URL || ""; +const sessionType = env.LANGBOT_DEBUG_CHAT_LOAD_SESSION_TYPE || env.LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE || "person"; +const requestsPerPipeline = positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_REQUESTS, 6); +const concurrency = Math.min(requestsPerPipeline * 2, positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_CONCURRENCY, 4)); +const timeoutMs = positiveInteger(env.LANGBOT_DEBUG_CHAT_LOAD_TIMEOUT_MS, 30_000); +const stream = bool(env.LANGBOT_DEBUG_CHAT_LOAD_STREAM, true); +const resetBeforeRun = bool(env.LANGBOT_DEBUG_CHAT_LOAD_RESET, true); +const responseP95BudgetMs = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_RESPONSE_P95_MS, 5_000); +const maxErrorRate = positiveNumber(env.LANGBOT_DEBUG_CHAT_LOAD_MAX_ERROR_RATE, 0); +const promptTemplate = env.LANGBOT_DEBUG_CHAT_LOAD_PROMPT_TEMPLATE + || "请只回复 \"{expected}\",不要解释,不要添加其他字符。"; +const failureSignals = textList(env.LANGBOT_E2E_FAILURE_SIGNALS || env.LANGBOT_DEBUG_CHAT_LOAD_FAILURE_SIGNALS || ""); + +const pipelineTargets = [ + { + label: "A", + expectedPrefix: "PIPEA", + otherPrefix: "PIPEB", + url: env.LANGBOT_FAKE_PROVIDER_PIPELINE_A_URL || "", + name: env.LANGBOT_FAKE_PROVIDER_PIPELINE_A_NAME || "", + }, + { + label: "B", + expectedPrefix: "PIPEB", + otherPrefix: "PIPEA", + url: env.LANGBOT_FAKE_PROVIDER_PIPELINE_B_URL || "", + name: env.LANGBOT_FAKE_PROVIDER_PIPELINE_B_NAME || "", + }, +]; + +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + status: "fail", + reason: "", + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + backend_url: backendUrl, + session_type: sessionType, + pipelines: [], + load_profile: { + requests_per_pipeline: requestsPerPipeline, + total_requests: requestsPerPipeline * 2, + concurrency, + timeout_ms: timeoutMs, + stream, + reset_before_run: resetBeforeRun, + }, + evidence: { + network_log: paths.networkLog, + metrics_json: metricsPath, + samples_json: samplesPath, + fake_provider_state_json: fakeProviderStatePath, + debug_chat_reset_diagnostic_json: resetDiagnosticPath, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["metrics", "network", "api_diagnostic", "filesystem"], +}; + +try { + if (!backendUrl) { + result.status = "env_issue"; + throw new Error("LANGBOT_BACKEND_URL is not configured."); + } + if (!["person", "group"].includes(sessionType)) { + throw new Error(`LANGBOT_DEBUG_CHAT_LOAD_SESSION_TYPE must be person or group, got ${sessionType}.`); + } + for (const target of pipelineTargets) { + if (!target.url && !target.name) { + result.status = "env_issue"; + throw new Error(`Set LANGBOT_FAKE_PROVIDER_PIPELINE_${target.label}_URL or LANGBOT_FAKE_PROVIDER_PIPELINE_${target.label}_NAME.`); + } + } + + const backendReady = await backendReachable(backendUrl); + if (!backendReady) { + result.status = "env_issue"; + throw new Error(`Backend did not respond at ${backendUrl}.`); + } + + const user = env.LANGBOT_E2E_LOGIN_USER || ""; + const password = env.LANGBOT_E2E_LOGIN_PASSWORD || DEFAULT_LOCAL_PASSWORD; + if (!user) { + result.status = "env_issue"; + throw new Error("LANGBOT_E2E_LOGIN_USER is required so this probe can resolve/reset Debug Chat sessions."); + } + const auth = await resetAndAuthLocalUser({ backendUrl, user, password }); + const pipelines = []; + for (const target of pipelineTargets) { + const pipeline = await resolvePipeline({ + backendUrl, + token: auth.token, + pipelineUrl: target.url, + pipelineName: target.name, + }); + pipelines.push({ + ...target, + id: pipeline.id, + name: pipeline.name || target.name, + wsUrl: websocketUrl(backendUrl, pipeline.id, sessionType), + }); + } + result.pipelines = pipelines.map((pipeline) => ({ + label: pipeline.label, + id: pipeline.id, + name: pipeline.name, + url: pipeline.url, + })); + + if (resetBeforeRun) { + const resetDiagnostics = []; + for (const pipeline of pipelines) { + const reset = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(pipeline.id)}/ws/reset/${encodeURIComponent(sessionType)}`, { + method: "POST", + token: auth.token, + }); + resetDiagnostics.push({ + pipeline_label: pipeline.label, + pipeline_id: pipeline.id, + status: isApiFailure(reset) ? "fail" : "ready", + http_status: reset.status, + code: reset.json.code ?? null, + reason: isApiFailure(reset) ? reset.json.msg || "Debug Chat reset failed." : "Debug Chat session reset.", + }); + } + await writeFile(resetDiagnosticPath, `${JSON.stringify(resetDiagnostics, null, 2)}\n`, "utf8"); + const failedReset = resetDiagnostics.find((item) => item.status === "fail"); + if (failedReset) throw new Error(failedReset.reason); + } + await resetFakeProvider(fakeProviderUrl); + + const jobs = []; + for (let index = 0; index < requestsPerPipeline; index += 1) { + for (const pipeline of pipelines) { + jobs.push({ ...pipeline, index }); + } + } + + const loadStartedAt = performance.now(); + const samples = await runLoad({ + jobs, + concurrency, + timeoutMs, + promptTemplate, + stream, + failureSignals, + }); + const loadDurationMs = performance.now() - loadStartedAt; + const fakeProviderState = await readFakeProviderState(fakeProviderUrl); + if (fakeProviderState) { + await writeFile(fakeProviderStatePath, `${JSON.stringify(fakeProviderState, null, 2)}\n`, "utf8"); + } + const metrics = buildMetrics({ + samples, + requestsPerPipeline, + concurrency, + timeoutMs, + loadDurationMs, + backendUrl, + sessionType, + fakeProviderState, + }); + const thresholds = buildThresholds(metrics); + const passed = Object.values(thresholds).every((item) => item.pass); + result.status = passed ? "pass" : "fail"; + result.reason = passed + ? "Debug Chat cross-pipeline isolation probe passed all thresholds." + : "Debug Chat cross-pipeline isolation probe found leaks, errors, or latency threshold breaches."; + result.metrics_summary = { + requests_per_pipeline: metrics.requests_per_pipeline, + total_requests: metrics.total_requests, + concurrency: metrics.concurrency, + ok_count: metrics.ok_count, + error_count: metrics.error_count, + cross_pipeline_leak_count: metrics.cross_pipeline_leak_count, + timeout_count: metrics.timeout_count, + error_rate: metrics.error_rate, + response_p95_ms: metrics.response_duration_ms.p95, + first_response_p95_ms: metrics.first_response_ms.p95, + throughput_rps: metrics.throughput_rps, + status_counts: metrics.status_counts, + by_pipeline: metrics.by_pipeline, + fake_provider_request_count: metrics.fake_provider?.request_count ?? null, + fake_provider_duration_p95_ms: metrics.provider_timing?.provider_duration_ms.p95 ?? null, + langbot_overhead_estimate_p95_ms: metrics.provider_timing?.langbot_overhead_estimate_ms.p95 ?? null, + send_to_provider_start_p95_ms: metrics.provider_timing?.send_to_provider_start_ms.p95 ?? null, + provider_finish_to_ws_final_p95_ms: metrics.provider_timing?.provider_finish_to_ws_final_ms.p95 ?? null, + }; + result.thresholds_summary = thresholds; + result.artifacts = { + metrics_json: metricsPath, + samples_json: samplesPath, + fake_provider_state_json: fakeProviderState ? fakeProviderStatePath : "", + network_log: paths.networkLog, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }; + + await writeFile(metricsPath, `${JSON.stringify({ ...metrics, thresholds }, null, 2)}\n`, "utf8"); + await writeFile(samplesPath, `${JSON.stringify(samples, null, 2)}\n`, "utf8"); +} catch (error) { + if (!["env_issue", "blocked"].includes(result.status)) { + result.status = looksLikeEnvIssue(error) ? "env_issue" : "fail"; + } + result.reason = result.reason || safeReason(error.message); +} finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + await mkdir(paths.evidenceDir, { recursive: true }); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +exit(result.status === "pass" ? 0 : result.status === "env_issue" || result.status === "blocked" ? 2 : 1); + +async function backendReachable(baseUrl) { + try { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/healthz`, { + signal: AbortSignal.timeout(3000), + }); + return response.status < 500; + } catch { + return false; + } +} + +async function resetFakeProvider(rootUrl) { + if (!rootUrl) return; + try { + await fetch(`${normalizeProviderRootUrl(rootUrl)}/__qa/reset`, { + method: "POST", + signal: AbortSignal.timeout(3000), + }); + } catch { + // Missing fake-provider diagnostics should not hide the isolation result. + } +} + +async function readFakeProviderState(rootUrl) { + if (!rootUrl) return null; + try { + const response = await fetch(`${normalizeProviderRootUrl(rootUrl)}/__qa/config`, { + signal: AbortSignal.timeout(3000), + }); + const json = await response.json().catch(() => ({})); + return { + status: response.ok && json.ok === true ? "loaded" : "unavailable", + url: normalizeProviderRootUrl(rootUrl), + http_status: response.status, + model: json.model || "", + config: json.config || {}, + request_count: Number.isFinite(json.request_count) ? json.request_count : null, + recent_requests: Array.isArray(json.recent_requests) ? json.recent_requests : [], + }; + } catch (error) { + return { + status: "unavailable", + url: normalizeProviderRootUrl(rootUrl), + reason: safeReason(error.message), + request_count: null, + recent_requests: [], + }; + } +} + +function normalizeProviderRootUrl(value) { + const trimmed = String(value || "").trim().replace(/\/$/, ""); + return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed; +} + +function pipelineIdFromUrl(url) { + if (!url) return ""; + try { + const parsed = new URL(url); + return parsed.searchParams.get("id") || ""; + } catch { + return ""; + } +} + +async function resolvePipeline({ backendUrl, token, pipelineUrl, pipelineName }) { + const idFromUrl = pipelineIdFromUrl(pipelineUrl); + if (idFromUrl) { + const response = await apiJson(backendUrl, `/api/v1/pipelines/${encodeURIComponent(idFromUrl)}`, { token }); + const pipeline = response.json.data?.pipeline; + if (isApiFailure(response) || !pipeline?.uuid) { + throw new Error(response.json.msg || `Could not load pipeline ${idFromUrl}.`); + } + return { id: pipeline.uuid, name: pipeline.name || "" }; + } + if (!pipelineName) { + throw new Error("Set pipeline URL or name before running this probe."); + } + const response = await apiJson(backendUrl, "/api/v1/pipelines", { token }); + if (isApiFailure(response)) { + throw new Error(response.json.msg || "Failed to list pipelines."); + } + const pipeline = (response.json.data?.pipelines || []).find((item) => item.name === pipelineName); + if (!pipeline?.uuid) { + throw new Error(`Could not find pipeline named ${pipelineName}.`); + } + return { id: pipeline.uuid, name: pipeline.name || pipelineName }; +} + +function isApiFailure(response) { + return response.status >= 400 || (response.json.code !== undefined && response.json.code !== 0); +} + +function websocketUrl(baseUrl, pipelineId, sessionTypeValue) { + const parsed = new URL(baseUrl); + parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:"; + parsed.pathname = `/api/v1/pipelines/${encodeURIComponent(pipelineId)}/ws/connect`; + parsed.search = `?session_type=${encodeURIComponent(sessionTypeValue)}`; + return parsed.toString(); +} + +async function runLoad(options) { + const samples = []; + const queue = [...options.jobs]; + const workers = Array.from({ length: options.concurrency }, async () => { + while (queue.length > 0) { + const job = queue.shift(); + if (!job) continue; + const sample = await runSingleRequest({ ...options, job }); + samples.push(sample); + } + }); + await Promise.all(workers); + return samples.sort((left, right) => ( + left.pipeline_label.localeCompare(right.pipeline_label) || left.index - right.index + )); +} + +function expectedForIndex(prefix, index) { + return `${prefix}-${String(index + 1).padStart(4, "0")}`; +} + +function promptForIndex(template, expected) { + return template.replaceAll("{expected}", expected); +} + +function runSingleRequest({ + job, + timeoutMs, + promptTemplate, + stream, + failureSignals, +}) { + return new Promise((resolvePromise) => { + const expected = expectedForIndex(job.expectedPrefix, job.index); + const prompt = promptForIndex(promptTemplate, expected); + const sample = { + index: job.index, + pipeline_label: job.label, + pipeline_id: job.id, + pipeline_name: job.name, + status: "running", + ok: false, + expected_text: expected, + expected_prefix: job.expectedPrefix, + other_prefix: job.otherPrefix, + prompt, + response_text: "", + started_at: new Date().toISOString(), + started_epoch_ms: Date.now(), + connected_at: null, + connected_epoch_ms: null, + sent_at: null, + sent_epoch_ms: null, + first_assistant_event_at: null, + first_assistant_event_epoch_ms: null, + first_assistant_event_ms: null, + first_assistant_content_at: null, + first_assistant_content_epoch_ms: null, + first_assistant_content_ms: null, + first_response_at: null, + first_response_epoch_ms: null, + connected_ms: null, + first_response_ms: null, + response_duration_ms: null, + finished_at: null, + finished_epoch_ms: null, + event_count: 0, + same_pipeline_foreign_response_count: 0, + cross_pipeline_leak_count: 0, + last_foreign_response_text: "", + error: "", + close_code: null, + close_reason: "", + }; + let closed = false; + let connectedAt = 0; + let sentAt = 0; + const startedPerf = performance.now(); + let client = null; + const timer = setTimeout(() => { + finish("timeout", `Timed out after ${timeoutMs} ms.`); + }, timeoutMs); + + client = openRawWebSocket(job.wsUrl, { + onOpen() { + connectedAt = performance.now(); + const now = Date.now(); + sample.connected_at = new Date(now).toISOString(); + sample.connected_epoch_ms = now; + sample.connected_ms = rounded(connectedAt - startedPerf); + }, + onMessage(text) { + sample.event_count += 1; + let data; + try { + data = JSON.parse(String(text || "")); + } catch (error) { + finish("error", `Invalid WebSocket JSON: ${error.message}`); + return; + } + appendLine(paths.networkLog, JSON.stringify({ + pipeline_label: job.label, + request_index: job.index, + type: data.type, + session_type: data.session_type || "", + role: data.data?.role || "", + is_final: data.data?.is_final ?? null, + content_preview: redact(String(data.data?.content || data.message || "").slice(0, 200)), + })).catch(() => {}); + + if (data.type === "connected") { + sentAt = performance.now(); + const now = Date.now(); + sample.sent_at = new Date(now).toISOString(); + sample.sent_epoch_ms = now; + client.send(JSON.stringify({ + type: "message", + message: [{ type: "Plain", text: prompt }], + stream, + })); + return; + } + if (data.type === "error") { + finish("error", data.message || "WebSocket error message."); + return; + } + if (data.type !== "response" || data.data?.role !== "assistant") return; + + const content = String(data.data.content || ""); + markFirstAssistantEvent(sample, sentAt); + if (content) sample.response_text = content; + if (content) markFirstAssistantContent(sample, sentAt); + if (containsPipelineToken(content, job.otherPrefix)) { + sample.cross_pipeline_leak_count += 1; + finish("cross_pipeline_leak", `Pipeline ${job.label} received response from ${job.otherPrefix}: ${content}`); + return; + } + if (content.includes(expected) && sample.first_response_ms === null && sentAt > 0) { + const now = Date.now(); + sample.first_response_at = new Date(now).toISOString(); + sample.first_response_epoch_ms = now; + sample.first_response_ms = rounded(performance.now() - sentAt); + } + if (data.data.is_final === true) { + const ok = sample.response_text.includes(expected); + if (ok) { + if (sample.first_response_ms === null && sentAt > 0) { + const now = Date.now(); + sample.first_response_at = new Date(now).toISOString(); + sample.first_response_epoch_ms = now; + sample.first_response_ms = rounded(performance.now() - sentAt); + } + finish("pass", ""); + } else if (matchesFailureSignal(sample.response_text, failureSignals)) { + finish("app_error", `Assistant final response matched a failure signal: ${sample.response_text}`); + } else if (containsPipelineToken(sample.response_text, job.expectedPrefix)) { + sample.same_pipeline_foreign_response_count += 1; + sample.last_foreign_response_text = sample.response_text; + } else { + finish("mismatch", `Final assistant response did not include ${expected}: ${sample.response_text}`); + } + } + }, + onError(error) { + finish("connection_error", `WebSocket connection error: ${error.message}`); + }, + onClose(event) { + sample.close_code = event.code; + sample.close_reason = event.reason || ""; + if (!closed) finish("closed", `WebSocket closed before final assistant response: ${event.code}`); + }, + }); + + function finish(status, reason) { + if (closed) return; + closed = true; + clearTimeout(timer); + sample.status = status; + sample.ok = status === "pass"; + sample.error = status === "timeout" && sample.same_pipeline_foreign_response_count > 0 + ? `${reason || ""} Saw ${sample.same_pipeline_foreign_response_count} same-pipeline foreign assistant response(s); last=${sample.last_foreign_response_text}` + : reason || ""; + if (sentAt > 0) sample.response_duration_ms = rounded(performance.now() - sentAt); + else sample.response_duration_ms = rounded(performance.now() - startedPerf); + const now = Date.now(); + sample.finished_at = new Date(now).toISOString(); + sample.finished_epoch_ms = now; + try { + client?.close(); + } catch { + // Closing a failed socket should not hide the sample result. + } + resolvePromise(sample); + } + }); +} + +function markFirstAssistantEvent(sample, sentAt) { + if (sample.first_assistant_event_ms !== null || sentAt <= 0) return; + const now = Date.now(); + sample.first_assistant_event_at = new Date(now).toISOString(); + sample.first_assistant_event_epoch_ms = now; + sample.first_assistant_event_ms = rounded(performance.now() - sentAt); +} + +function markFirstAssistantContent(sample, sentAt) { + if (sample.first_assistant_content_ms !== null || sentAt <= 0) return; + const now = Date.now(); + sample.first_assistant_content_at = new Date(now).toISOString(); + sample.first_assistant_content_epoch_ms = now; + sample.first_assistant_content_ms = rounded(performance.now() - sentAt); +} + +function containsPipelineToken(text, prefix) { + const escaped = String(prefix).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}-\\d{4}`).test(String(text || "")); +} + +function matchesFailureSignal(text, signals) { + const lower = String(text || "").toLowerCase(); + return signals.some((signal) => lower.includes(signal.toLowerCase())); +} + +function openRawWebSocket(wsUrl, handlers) { + const parsed = new URL(wsUrl); + const secure = parsed.protocol === "wss:"; + const port = Number(parsed.port || (secure ? 443 : 80)); + const host = parsed.hostname; + const path = `${parsed.pathname}${parsed.search}`; + const key = crypto.randomBytes(16).toString("base64"); + const socket = secure + ? tls.connect({ host, port, servername: host }) + : net.connect({ host, port }); + let opened = false; + let closed = false; + let buffer = Buffer.alloc(0); + + socket.setNoDelay(true); + socket.on("connect", () => { + const originProtocol = secure ? "https" : "http"; + const request = [ + `GET ${path} HTTP/1.1`, + `Host: ${parsed.host}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13", + `Origin: ${originProtocol}://${parsed.host}`, + "", + "", + ].join("\r\n"); + socket.write(request); + }); + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!opened) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const headerText = buffer.slice(0, headerEnd).toString("utf8"); + buffer = buffer.slice(headerEnd + 4); + if (!/^HTTP\/1\.1 101\b/i.test(headerText)) { + handlers.onError(new Error(`Handshake failed: ${headerText.split("\r\n")[0] || "missing status"}`)); + socket.destroy(); + return; + } + opened = true; + handlers.onOpen(); + } + processFrames(); + }); + socket.on("error", (error) => { + if (!closed) handlers.onError(error); + }); + socket.on("close", () => { + if (closed) return; + closed = true; + handlers.onClose({ code: null, reason: "" }); + }); + + function processFrames() { + while (true) { + const frame = readFrame(buffer); + if (!frame) return; + buffer = buffer.slice(frame.consumed); + if (frame.opcode === 0x1) { + handlers.onMessage(frame.payload.toString("utf8")); + } else if (frame.opcode === 0x8) { + const code = frame.payload.length >= 2 ? frame.payload.readUInt16BE(0) : null; + const reason = frame.payload.length > 2 ? frame.payload.slice(2).toString("utf8") : ""; + closed = true; + handlers.onClose({ code, reason }); + socket.end(); + return; + } else if (frame.opcode === 0x9) { + writeFrame(socket, 0xA, frame.payload); + } + } + } + + return { + send(text) { + if (closed || !opened) return; + writeFrame(socket, 0x1, Buffer.from(text, "utf8")); + }, + close() { + if (closed) return; + closed = true; + if (!socket.destroyed) { + if (opened) writeFrame(socket, 0x8, Buffer.alloc(0)); + setTimeout(() => socket.end(), 50).unref(); + } + }, + }; +} + +function readFrame(buffer) { + if (buffer.length < 2) return null; + const first = buffer[0]; + const second = buffer[1]; + const opcode = first & 0x0f; + const masked = Boolean(second & 0x80); + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + if (buffer.length < offset + 2) return null; + length = buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + if (buffer.length < offset + 8) return null; + const high = buffer.readUInt32BE(offset); + const low = buffer.readUInt32BE(offset + 4); + length = high * 2 ** 32 + low; + offset += 8; + } + let mask = null; + if (masked) { + if (buffer.length < offset + 4) return null; + mask = buffer.slice(offset, offset + 4); + offset += 4; + } + if (buffer.length < offset + length) return null; + let payload = buffer.slice(offset, offset + length); + if (mask) { + payload = Buffer.from(payload); + for (let index = 0; index < payload.length; index += 1) { + payload[index] ^= mask[index % 4]; + } + } + return { + opcode, + payload, + consumed: offset + length, + }; +} + +function writeFrame(socket, opcode, payload) { + const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload || ""); + const mask = crypto.randomBytes(4); + const headerLength = body.length < 126 ? 2 : body.length <= 0xffff ? 4 : 10; + const header = Buffer.alloc(headerLength); + header[0] = 0x80 | opcode; + if (body.length < 126) { + header[1] = 0x80 | body.length; + } else if (body.length <= 0xffff) { + header[1] = 0x80 | 126; + header.writeUInt16BE(body.length, 2); + } else { + header[1] = 0x80 | 127; + header.writeUInt32BE(Math.floor(body.length / 2 ** 32), 2); + header.writeUInt32BE(body.length >>> 0, 6); + } + const masked = Buffer.from(body); + for (let index = 0; index < masked.length; index += 1) { + masked[index] ^= mask[index % 4]; + } + socket.write(Buffer.concat([header, mask, masked])); +} + +function buildMetrics({ samples, requestsPerPipeline, concurrency, timeoutMs, loadDurationMs, backendUrl, sessionType, fakeProviderState }) { + const okSamples = samples.filter((sample) => sample.ok); + const statusCounts = {}; + const byPipeline = {}; + for (const sample of samples) { + statusCounts[sample.status] = (statusCounts[sample.status] || 0) + 1; + if (!byPipeline[sample.pipeline_label]) { + byPipeline[sample.pipeline_label] = { + ok_count: 0, + error_count: 0, + cross_pipeline_leak_count: 0, + timeout_count: 0, + }; + } + if (sample.ok) byPipeline[sample.pipeline_label].ok_count += 1; + else byPipeline[sample.pipeline_label].error_count += 1; + byPipeline[sample.pipeline_label].cross_pipeline_leak_count += sample.cross_pipeline_leak_count || 0; + if (sample.status === "timeout") byPipeline[sample.pipeline_label].timeout_count += 1; + } + const errorCount = samples.length - okSamples.length; + return { + probe: caseId, + backend_url: backendUrl, + session_type: sessionType, + requests_per_pipeline: requestsPerPipeline, + total_requests: requestsPerPipeline * 2, + completed_requests: samples.length, + concurrency, + timeout_ms: timeoutMs, + ok_count: okSamples.length, + error_count: errorCount, + timeout_count: samples.filter((sample) => sample.status === "timeout").length, + cross_pipeline_leak_count: samples.reduce((count, sample) => count + (sample.cross_pipeline_leak_count || 0), 0), + error_rate: samples.length === 0 ? 1 : rounded(errorCount / samples.length), + load_duration_ms: rounded(loadDurationMs), + throughput_rps: loadDurationMs <= 0 ? 0 : rounded(okSamples.length / (loadDurationMs / 1000)), + status_counts: statusCounts, + by_pipeline: byPipeline, + connected_ms: stats(samples.map((sample) => sample.connected_ms).filter(Number.isFinite)), + first_assistant_event_ms: stats(samples.map((sample) => sample.first_assistant_event_ms).filter(Number.isFinite)), + first_assistant_content_ms: stats(samples.map((sample) => sample.first_assistant_content_ms).filter(Number.isFinite)), + first_response_ms: stats(okSamples.map((sample) => sample.first_response_ms).filter(Number.isFinite)), + response_duration_ms: stats(okSamples.map((sample) => sample.response_duration_ms).filter(Number.isFinite)), + fake_provider: summarizeFakeProviderState(fakeProviderState), + provider_timing: buildProviderTimingMetrics(samples, fakeProviderState), + samples, + }; +} + +function buildThresholds(metrics) { + return { + cross_pipeline_leak_count: { + actual: metrics.cross_pipeline_leak_count, + max: 0, + pass: metrics.cross_pipeline_leak_count === 0, + }, + error_rate: { + actual: metrics.error_rate, + max: maxErrorRate, + pass: metrics.error_rate <= maxErrorRate, + }, + response_p95_ms: { + actual: metrics.response_duration_ms.p95, + max: responseP95BudgetMs, + pass: metrics.ok_count > 0 && metrics.response_duration_ms.p95 <= responseP95BudgetMs, + }, + }; +} + +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(String(value || ""), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function positiveNumber(value, fallback) { + const parsed = Number(value || ""); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function bool(value, fallback) { + if (value === undefined || value === "") return fallback; + if (/^(1|true|yes|on)$/i.test(String(value))) return true; + if (/^(0|false|no|off)$/i.test(String(value))) return false; + return fallback; +} + +function textList(value) { + return String(value || "") + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function rounded(value) { + return Number(value.toFixed(3)); +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return rounded(sorted[index]); +} + +function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: rounded(Math.min(...values)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: rounded(Math.max(...values)), + }; +} + +function looksLikeEnvIssue(error) { + const message = String(error?.message || error || ""); + return /fetch failed|ECONNREFUSED|ENOTFOUND|LANGBOT_.*not configured|Could not read recovery_key|Backend did not respond/i.test(message); +} + +function safeReason(value) { + return redact(String(value || "")).slice(0, 1000); +} diff --git a/skills/skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs b/skills/skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs new file mode 100755 index 0000000..8c9628e --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; + +function pad(value, size = 2) { + return String(value).padStart(size, "0"); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +const scenarios = [ + { + id: "provider-timeout", + target: "provider", + injected_fault: "fake provider request exceeds the configured timeout", + expected_status: "env_issue", + recovery_check: "provider route is reachable or the case remains outside product pass/fail", + cleanup: "stop fake provider or reset proxy route", + }, + { + id: "plugin-runtime-disconnect", + target: "plugin-runtime", + injected_fault: "runtime control channel disconnects during an action", + expected_status: "fail", + recovery_check: "runtime reconnects and a deterministic plugin action succeeds", + cleanup: "restart the local plugin runtime process", + }, + { + id: "mcp-stdio-server-exit", + target: "mcp", + injected_fault: "stdio server exits mid-call", + expected_status: "fail", + recovery_check: "server can be registered again and exposes the expected tool", + cleanup: "remove temporary MCP server registration", + }, + { + id: "operator-missing-login", + target: "webui", + injected_fault: "browser profile is not authenticated", + expected_status: "blocked", + recovery_check: "authenticated profile can open the same WebUI origin", + cleanup: "no product cleanup; refresh local login state", + }, + { + id: "transient-marketplace-timeout", + target: "marketplace", + injected_fault: "marketplace request times out once and then succeeds", + expected_status: "flaky", + recovery_check: "rerun passes with the same product revision and no code change", + cleanup: "clear retry-only evidence and keep the run classified as flaky", + }, +]; + +function validateScenario(scenario) { + const missing = ["id", "target", "injected_fault", "expected_status", "recovery_check", "cleanup"] + .filter((key) => !scenario[key]); + const allowedStatuses = new Set(["pass", "fail", "blocked", "env_issue", "flaky"]); + return { + id: scenario.id, + pass: missing.length === 0 && allowedStatuses.has(scenario.expected_status), + missing, + expected_status: scenario.expected_status, + }; +} + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "langbot-fault-taxonomy-contract"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + + const startedAt = new Date(); + const validations = scenarios.map(validateScenario); + const statusCounts = {}; + for (const scenario of scenarios) { + statusCounts[scenario.expected_status] = (statusCounts[scenario.expected_status] || 0) + 1; + } + const metrics = { + probe: caseId, + scenario_count: scenarios.length, + status_counts: statusCounts, + scenarios, + validations, + }; + const thresholds = { + scenario_count: { actual: scenarios.length, min: 5, pass: scenarios.length >= 5 }, + invalid_scenario_count: { + actual: validations.filter((item) => !item.pass).length, + max: 0, + pass: validations.every((item) => item.pass), + }, + cleanup_declared_count: { + actual: scenarios.filter((item) => item.cleanup).length, + min: scenarios.length, + pass: scenarios.every((item) => item.cleanup), + }, + }; + const status = Object.values(thresholds).every((item) => item.pass) ? "pass" : "fail"; + const metricsPath = join(evidenceDir, "metrics.json"); + const faultModelPath = join(evidenceDir, "fault-model.json"); + const automationResultPath = join(evidenceDir, "automation-result.json"); + const resultPath = join(evidenceDir, "result.json"); + + await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8"); + await writeFile(faultModelPath, `${JSON.stringify({ scenarios }, null, 2)}\n`, "utf8"); + + const finishedAt = new Date(); + const result = { + source: "automation", + case_id: caseId, + run_id: runId, + status, + reason: status === "pass" + ? "Fault taxonomy contract declares status, recovery, and cleanup for every scenario." + : "Fault taxonomy contract is missing required scenario fields.", + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: finishedAt.toISOString(), + finished_at_local: localIsoWithOffset(finishedAt), + duration_ms: finishedAt.getTime() - startedAt.getTime(), + metrics_summary: { + scenario_count: metrics.scenario_count, + status_counts: metrics.status_counts, + invalid_scenario_count: thresholds.invalid_scenario_count.actual, + }, + thresholds_summary: thresholds, + artifacts: { + metrics_json: metricsPath, + fault_model_json: faultModelPath, + automation_result_json: automationResultPath, + result_json: resultPath, + }, + evidence_collected: ["metrics", "filesystem"], + }; + + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultPath, resultText, "utf8"); + await writeFile(resultPath, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + exit(status === "pass" ? 0 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/langbot-live-backend-latency.mjs b/skills/skills/langbot-testing/probes/langbot-live-backend-latency.mjs new file mode 100755 index 0000000..747c84c --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-live-backend-latency.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; + +function pad(value, size = 2) { + return String(value).padStart(size, "0"); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return Number(sorted[index].toFixed(3)); +} + +function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: Number(Math.min(...values).toFixed(3)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: Number(Math.max(...values).toFixed(3)), + }; +} + +function parseJsonList(value, fallback) { + if (!value) return fallback; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : fallback; + } catch { + return fallback; + } +} + +function joinUrl(baseUrl, path) { + const base = baseUrl.replace(/\/+$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${base}${suffix}`; +} + +async function fetchOnce(url, timeoutMs) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const started = performance.now(); + try { + const response = await fetch(url, { method: "GET", signal: controller.signal }); + await response.arrayBuffer(); + const latencyMs = performance.now() - started; + return { + url, + ok: response.status < 500, + status: response.status, + latency_ms: Number(latencyMs.toFixed(3)), + error: "", + }; + } catch (error) { + const latencyMs = performance.now() - started; + return { + url, + ok: false, + status: 0, + latency_ms: Number(latencyMs.toFixed(3)), + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } +} + +async function runBatches(urls, totalRequests, concurrency, timeoutMs) { + const queue = Array.from({ length: totalRequests }, (_, index) => urls[index % urls.length]); + const results = []; + while (queue.length > 0) { + const batch = queue.splice(0, concurrency); + results.push(...await Promise.all(batch.map((url) => fetchOnce(url, timeoutMs)))); + } + return results; +} + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "langbot-live-backend-latency"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + + const startedAt = new Date(); + const backendUrl = env.LANGBOT_BACKEND_URL || ""; + const endpoints = parseJsonList(env.LANGBOT_PERF_ENDPOINTS_JSON, ["/healthz"]); + const totalRequests = Number(env.LANGBOT_PERF_REQUESTS || "12"); + const concurrency = Number(env.LANGBOT_PERF_CONCURRENCY || "2"); + const timeoutMs = Number(env.LANGBOT_PERF_TIMEOUT_MS || "5000"); + const p95BudgetMs = Number(env.LANGBOT_PERF_BACKEND_P95_MS || "1000"); + const maxErrorRate = Number(env.LANGBOT_PERF_MAX_ERROR_RATE || "0"); + const metricsPath = join(evidenceDir, "metrics.json"); + const networkLogPath = join(evidenceDir, "network.log"); + const automationResultPath = join(evidenceDir, "automation-result.json"); + const resultPath = join(evidenceDir, "result.json"); + + let status = "fail"; + let reason = ""; + let results = []; + if (!backendUrl) { + status = "env_issue"; + reason = "LANGBOT_BACKEND_URL is not configured."; + } else { + const urls = endpoints.map((path) => joinUrl(backendUrl, path)); + results = await runBatches(urls, totalRequests, concurrency, timeoutMs); + const okCount = results.filter((item) => item.ok).length; + const errorCount = results.length - okCount; + const errorRate = results.length === 0 ? 1 : errorCount / results.length; + const latencies = results.filter((item) => item.ok).map((item) => item.latency_ms); + const latencyStats = stats(latencies); + const allConnectionFailures = results.length > 0 && results.every((item) => item.status === 0); + if (allConnectionFailures) { + status = "env_issue"; + reason = `Backend did not respond at ${backendUrl}.`; + } else if (latencyStats.p95 <= p95BudgetMs && errorRate <= maxErrorRate) { + status = "pass"; + reason = "Live backend latency probe passed all thresholds."; + } else { + status = "fail"; + reason = "Live backend latency probe breached latency or error-rate thresholds."; + } + } + + const statusCounts = {}; + for (const item of results) { + const key = item.status === 0 ? "network_error" : String(item.status); + statusCounts[key] = (statusCounts[key] || 0) + 1; + } + const okResults = results.filter((item) => item.ok); + const metrics = { + probe: caseId, + backend_url: backendUrl, + endpoints, + total_requests: totalRequests, + concurrency, + timeout_ms: timeoutMs, + ok_count: okResults.length, + error_count: results.length - okResults.length, + error_rate: results.length === 0 ? 1 : Number(((results.length - okResults.length) / results.length).toFixed(4)), + latency_ms: stats(okResults.map((item) => item.latency_ms)), + status_counts: statusCounts, + }; + const thresholds = { + backend_p95_ms: { actual: metrics.latency_ms.p95, max: p95BudgetMs, pass: metrics.latency_ms.p95 <= p95BudgetMs }, + error_rate: { actual: metrics.error_rate, max: maxErrorRate, pass: metrics.error_rate <= maxErrorRate }, + }; + + await writeFile(metricsPath, `${JSON.stringify({ ...metrics, samples: results }, null, 2)}\n`, "utf8"); + await writeFile(networkLogPath, results.map((item) => JSON.stringify(item)).join("\n") + (results.length > 0 ? "\n" : ""), "utf8"); + + const finishedAt = new Date(); + const result = { + source: "automation", + case_id: caseId, + run_id: runId, + status, + reason, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: finishedAt.toISOString(), + finished_at_local: localIsoWithOffset(finishedAt), + duration_ms: finishedAt.getTime() - startedAt.getTime(), + url: backendUrl, + metrics_summary: { + requests: metrics.total_requests, + concurrency: metrics.concurrency, + ok_count: metrics.ok_count, + error_rate: metrics.error_rate, + latency_p50_ms: metrics.latency_ms.p50, + latency_p95_ms: metrics.latency_ms.p95, + status_counts: metrics.status_counts, + }, + thresholds_summary: thresholds, + artifacts: { + metrics_json: metricsPath, + network_log: networkLogPath, + automation_result_json: automationResultPath, + result_json: resultPath, + }, + evidence_collected: ["metrics", "network", "api_diagnostic", "filesystem"], + }; + + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultPath, resultText, "utf8"); + await writeFile(resultPath, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + exit(status === "pass" ? 0 : status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/langbot-live-backend-log-health.mjs b/skills/skills/langbot-testing/probes/langbot-live-backend-log-health.mjs new file mode 100755 index 0000000..38a31c3 --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-live-backend-log-health.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; + +function pad(value, size = 2) { + return String(value).padStart(size, "0"); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function repoRootFromEnv(root) { + return env.LANGBOT_REPO ? resolve(env.LANGBOT_REPO) : resolve(root, ".."); +} + +function latestBackendLog(root) { + const explicit = env.LANGBOT_BACKEND_LOG; + if (explicit) return resolve(explicit); + + const logsDir = join(repoRootFromEnv(root), "data", "logs"); + if (!existsSync(logsDir)) return ""; + const candidates = readdirSync(logsDir) + .filter((name) => /^langbot-.*\.log$/.test(name)) + .map((name) => join(logsDir, name)) + .filter((path) => { + try { + return statSync(path).isFile(); + } catch { + return false; + } + }) + .sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs); + return candidates[0] || ""; +} + +function parseSince(startedAt) { + if (env.LANGBOT_BACKEND_LOG_SINCE) return new Date(env.LANGBOT_BACKEND_LOG_SINCE); + const lookbackSeconds = Number(env.LANGBOT_BACKEND_LOG_LOOKBACK_SECONDS || "300"); + return new Date(startedAt.getTime() - lookbackSeconds * 1000); +} + +function parseTimestamp(line, year) { + const localMatch = line.match(/^\[(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})\]/); + if (localMatch) { + const [, month, day, hour, minute, second, millisecond] = localMatch; + return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millisecond}+08:00`); + } + + const accessMatch = line.match(/^\[(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})\]/); + if (accessMatch) { + const [, fullYear, month, day, hour, minute, second, offset] = accessMatch; + const normalizedOffset = `${offset.slice(0, 3)}:${offset.slice(3)}`; + return new Date(`${fullYear}-${month}-${day}T${hour}:${minute}:${second}${normalizedOffset}`); + } + + return null; +} + +function findingForLine(line, number) { + const rules = [ + { severity: "fail", kind: "python_traceback", pattern: /\bTraceback(?: \(most recent call last\))?/i }, + { severity: "fail", kind: "unretrieved_task_exception", pattern: /Task exception was never retrieved/i }, + { severity: "fail", kind: "unawaited_coroutine", pattern: /RuntimeWarning:\s+coroutine .* was never awaited/i }, + { severity: "fail", kind: "unclosed_client_session", pattern: /Unclosed client session/i }, + { severity: "fail", kind: "unclosed_connector", pattern: /Unclosed connector/i }, + { severity: "fail", kind: "import_error", pattern: /\bImportError\b/i }, + { severity: "fail", kind: "error_log", pattern: /\b(?:ERROR|CRITICAL)\b/ }, + { severity: "warning", kind: "warning_log", pattern: /\bWARNING\b/ }, + ]; + + for (const rule of rules) { + if (rule.pattern.test(line)) { + return { + severity: rule.severity, + kind: rule.kind, + line: number, + excerpt: line, + }; + } + } + return null; +} + +function scanLines(text, since, year) { + const findings = []; + const scanned = []; + let includeContinuation = false; + const lines = text.split(/\r?\n/); + for (const [index, line] of lines.entries()) { + const number = index + 1; + const timestamp = parseTimestamp(line, year); + if (timestamp) includeContinuation = timestamp >= since; + if (!includeContinuation) continue; + scanned.push({ number, text: line }); + const finding = findingForLine(line, number); + if (finding) findings.push(finding); + } + return { findings, scanned, total_lines: lines.length }; +} + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "langbot-live-backend-log-health"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + + const startedAt = new Date(); + const since = parseSince(startedAt); + const logPath = latestBackendLog(root); + const metricsPath = join(evidenceDir, "metrics.json"); + const findingsPath = join(evidenceDir, "findings.json"); + const scannedLogPath = join(evidenceDir, "scanned-backend.log"); + const automationResultPath = join(evidenceDir, "automation-result.json"); + const resultPath = join(evidenceDir, "result.json"); + + let status = "fail"; + let reason = ""; + let scan = { findings: [], scanned: [], total_lines: 0 }; + if (!logPath || !existsSync(logPath)) { + status = "env_issue"; + reason = "No LangBot backend log file was found. Set LANGBOT_BACKEND_LOG or LANGBOT_REPO."; + } else { + const text = await readFile(logPath, "utf8"); + scan = scanLines(text, since, startedAt.getFullYear()); + const failCount = scan.findings.filter((item) => item.severity === "fail").length; + status = failCount === 0 ? "pass" : "fail"; + reason = status === "pass" + ? "Live backend log health passed; no fail-severity findings in the scanned window." + : "Live backend log health found fail-severity backend log findings."; + } + + const warningCount = scan.findings.filter((item) => item.severity === "warning").length; + const failCount = scan.findings.filter((item) => item.severity === "fail").length; + const metrics = { + probe: caseId, + backend_log: logPath, + since: since.toISOString(), + scanned_line_count: scan.scanned.length, + total_line_count: scan.total_lines, + fail_count: failCount, + warning_count: warningCount, + finding_count: scan.findings.length, + }; + const thresholds = { + fail_count: { actual: failCount, max: 0, pass: failCount === 0 }, + }; + + await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8"); + await writeFile(findingsPath, `${JSON.stringify(scan.findings, null, 2)}\n`, "utf8"); + await writeFile(scannedLogPath, scan.scanned.map((item) => `${item.number}: ${item.text}`).join("\n") + (scan.scanned.length > 0 ? "\n" : ""), "utf8"); + + const finishedAt = new Date(); + const result = { + source: "automation", + case_id: caseId, + run_id: runId, + status, + reason, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: finishedAt.toISOString(), + finished_at_local: localIsoWithOffset(finishedAt), + duration_ms: finishedAt.getTime() - startedAt.getTime(), + url: logPath, + metrics_summary: { + scanned_line_count: metrics.scanned_line_count, + fail_count: metrics.fail_count, + warning_count: metrics.warning_count, + finding_count: metrics.finding_count, + }, + thresholds_summary: thresholds, + artifacts: { + metrics_json: metricsPath, + findings_json: findingsPath, + scanned_backend_log: scannedLogPath, + automation_result_json: automationResultPath, + result_json: resultPath, + }, + evidence_collected: ["metrics", "backend_log", "filesystem"], + }; + + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultPath, resultText, "utf8"); + await writeFile(resultPath, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + exit(status === "pass" ? 0 : status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/langbot-live-control-plane-api.mjs b/skills/skills/langbot-testing/probes/langbot-live-control-plane-api.mjs new file mode 100755 index 0000000..8232d1f --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-live-control-plane-api.mjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; + +function pad(value, size = 2) { + return String(value).padStart(size, "0"); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return Number(sorted[index].toFixed(3)); +} + +function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: Number(Math.min(...values).toFixed(3)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: Number(Math.max(...values).toFixed(3)), + }; +} + +function joinUrl(baseUrl, path) { + const base = baseUrl.replace(/\/+$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${base}${suffix}`; +} + +function parseJsonObject(value, fallback) { + if (!value) return fallback; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback; + } catch { + return fallback; + } +} + +function controlPlaneEndpoints() { + return [ + { + id: "healthz", + path: "/healthz", + expected_status: 200, + expected_code: 0, + p95_budget_ms: Number(env.LANGBOT_PERF_HEALTHZ_P95_MS || "500"), + required_data_fields: [], + }, + { + id: "system_info", + path: "/api/v1/system/info", + expected_status: 200, + expected_code: 0, + p95_budget_ms: Number(env.LANGBOT_PERF_SYSTEM_INFO_P95_MS || "1000"), + required_data_fields: ["version", "edition", "enable_marketplace"], + }, + ]; +} + +async function fetchEndpoint(backendUrl, endpoint, timeoutMs) { + const url = joinUrl(backendUrl, endpoint.path); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const started = performance.now(); + let bodyText = ""; + let json = null; + let jsonValid = false; + let error = ""; + + try { + const response = await fetch(url, { + method: "GET", + headers: { "accept": "application/json" }, + signal: controller.signal, + }); + bodyText = await response.text(); + try { + json = bodyText ? JSON.parse(bodyText) : null; + jsonValid = json !== null; + } catch (parseError) { + error = parseError instanceof Error ? parseError.message : String(parseError); + } + + const data = json && typeof json === "object" && json.data && typeof json.data === "object" ? json.data : {}; + const missingFields = endpoint.required_data_fields.filter((field) => !(field in data)); + const statusOk = response.status === endpoint.expected_status; + const codeOk = !json || typeof json !== "object" ? false : json.code === endpoint.expected_code; + const shapeOk = jsonValid && missingFields.length === 0; + const latencyMs = performance.now() - started; + return { + endpoint_id: endpoint.id, + path: endpoint.path, + url, + status: response.status, + ok: statusOk && codeOk && shapeOk, + status_ok: statusOk, + code_ok: codeOk, + json_valid: jsonValid, + missing_fields: missingFields, + response_code: json && typeof json === "object" ? json.code : null, + latency_ms: Number(latencyMs.toFixed(3)), + error, + }; + } catch (fetchError) { + const latencyMs = performance.now() - started; + return { + endpoint_id: endpoint.id, + path: endpoint.path, + url, + status: 0, + ok: false, + status_ok: false, + code_ok: false, + json_valid: false, + missing_fields: endpoint.required_data_fields, + response_code: null, + latency_ms: Number(latencyMs.toFixed(3)), + error: fetchError instanceof Error ? fetchError.message : String(fetchError), + }; + } finally { + clearTimeout(timeout); + } +} + +async function runBatches(backendUrl, endpoints, totalRequests, concurrency, timeoutMs) { + const queue = Array.from({ length: totalRequests }, (_, index) => endpoints[index % endpoints.length]); + const results = []; + while (queue.length > 0) { + const batch = queue.splice(0, concurrency); + results.push(...await Promise.all(batch.map((endpoint) => fetchEndpoint(backendUrl, endpoint, timeoutMs)))); + } + return results; +} + +function endpointMetrics(endpoints, results) { + return Object.fromEntries(endpoints.map((endpoint) => { + const samples = results.filter((item) => item.endpoint_id === endpoint.id); + const okSamples = samples.filter((item) => item.ok); + return [ + endpoint.id, + { + path: endpoint.path, + requests: samples.length, + ok_count: okSamples.length, + error_rate: samples.length === 0 ? 1 : Number(((samples.length - okSamples.length) / samples.length).toFixed(4)), + latency_ms: stats(okSamples.map((item) => item.latency_ms)), + p95_budget_ms: endpoint.p95_budget_ms, + }, + ]; + })); +} + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "langbot-live-control-plane-api"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + + const startedAt = new Date(); + const backendUrl = env.LANGBOT_BACKEND_URL || ""; + const endpoints = controlPlaneEndpoints(); + const configuredBudgets = parseJsonObject(env.LANGBOT_CONTROL_PLANE_P95_BUDGETS_JSON, {}); + for (const endpoint of endpoints) { + const budget = configuredBudgets[endpoint.id]; + if (typeof budget === "number" && Number.isFinite(budget)) endpoint.p95_budget_ms = budget; + } + const totalRequests = Number(env.LANGBOT_CONTROL_PLANE_REQUESTS || "20"); + const concurrency = Number(env.LANGBOT_CONTROL_PLANE_CONCURRENCY || "4"); + const timeoutMs = Number(env.LANGBOT_CONTROL_PLANE_TIMEOUT_MS || "5000"); + const maxErrorRate = Number(env.LANGBOT_CONTROL_PLANE_MAX_ERROR_RATE || "0"); + const metricsPath = join(evidenceDir, "metrics.json"); + const endpointsPath = join(evidenceDir, "endpoints.json"); + const networkLogPath = join(evidenceDir, "network.log"); + const automationResultPath = join(evidenceDir, "automation-result.json"); + const resultPath = join(evidenceDir, "result.json"); + + let status = "fail"; + let reason = ""; + let results = []; + if (!backendUrl) { + status = "env_issue"; + reason = "LANGBOT_BACKEND_URL is not configured."; + } else { + results = await runBatches(backendUrl, endpoints, totalRequests, concurrency, timeoutMs); + const allConnectionFailures = results.length > 0 && results.every((item) => item.status === 0); + if (allConnectionFailures) { + status = "env_issue"; + reason = `Backend did not respond at ${backendUrl}.`; + } + } + + const okResults = results.filter((item) => item.ok); + const statusCounts = {}; + for (const item of results) { + const key = item.status === 0 ? "network_error" : String(item.status); + statusCounts[key] = (statusCounts[key] || 0) + 1; + } + const perEndpoint = endpointMetrics(endpoints, results); + const responseShapeFailures = results.filter((item) => !item.json_valid || item.missing_fields.length > 0 || !item.code_ok).length; + const errorRate = results.length === 0 ? 1 : Number(((results.length - okResults.length) / results.length).toFixed(4)); + const thresholds = { + error_rate: { actual: errorRate, max: maxErrorRate, pass: errorRate <= maxErrorRate }, + response_shape_failures: { actual: responseShapeFailures, max: 0, pass: responseShapeFailures === 0 }, + }; + for (const endpoint of endpoints) { + const actual = perEndpoint[endpoint.id].latency_ms.p95; + thresholds[`${endpoint.id}_p95_ms`] = { + actual, + max: endpoint.p95_budget_ms, + pass: actual <= endpoint.p95_budget_ms, + }; + } + + if (status !== "env_issue") { + const passed = Object.values(thresholds).every((item) => item.pass); + status = passed ? "pass" : "fail"; + reason = passed + ? "Live control-plane API probe passed all thresholds." + : "Live control-plane API probe breached shape, latency, or error-rate thresholds."; + } + + const metrics = { + probe: caseId, + backend_url: backendUrl, + total_requests: totalRequests, + concurrency, + timeout_ms: timeoutMs, + ok_count: okResults.length, + error_count: results.length - okResults.length, + error_rate: errorRate, + status_counts: statusCounts, + response_shape_failures: responseShapeFailures, + endpoints: perEndpoint, + }; + + await writeFile(metricsPath, `${JSON.stringify({ ...metrics, samples: results }, null, 2)}\n`, "utf8"); + await writeFile(endpointsPath, `${JSON.stringify(endpoints, null, 2)}\n`, "utf8"); + await writeFile(networkLogPath, results.map((item) => JSON.stringify(item)).join("\n") + (results.length > 0 ? "\n" : ""), "utf8"); + + const finishedAt = new Date(); + const result = { + source: "automation", + case_id: caseId, + run_id: runId, + status, + reason, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: finishedAt.toISOString(), + finished_at_local: localIsoWithOffset(finishedAt), + duration_ms: finishedAt.getTime() - startedAt.getTime(), + url: backendUrl, + metrics_summary: { + requests: metrics.total_requests, + concurrency: metrics.concurrency, + ok_count: metrics.ok_count, + error_rate: metrics.error_rate, + response_shape_failures: metrics.response_shape_failures, + endpoints: Object.fromEntries(Object.entries(metrics.endpoints).map(([id, value]) => [ + id, + { + path: value.path, + ok_count: value.ok_count, + error_rate: value.error_rate, + latency_p50_ms: value.latency_ms.p50, + latency_p95_ms: value.latency_ms.p95, + }, + ])), + status_counts: metrics.status_counts, + }, + thresholds_summary: thresholds, + artifacts: { + metrics_json: metricsPath, + endpoints_json: endpointsPath, + network_log: networkLogPath, + automation_result_json: automationResultPath, + result_json: resultPath, + }, + evidence_collected: ["metrics", "network", "api_diagnostic", "filesystem"], + }; + + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultPath, resultText, "utf8"); + await writeFile(resultPath, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + exit(status === "pass" ? 0 : status === "env_issue" ? 2 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs b/skills/skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs new file mode 100755 index 0000000..5338df0 --- /dev/null +++ b/skills/skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { env, exit } from "node:process"; + +function pad(value, size = 2) { + return String(value).padStart(size, "0"); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return Number(sorted[index].toFixed(3)); +} + +function stats(values) { + return { + min: Number(Math.min(...values).toFixed(3)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: Number(Math.max(...values).toFixed(3)), + }; +} + +function threshold(actual, limit, operator) { + const pass = operator === "<=" ? actual <= limit : actual >= limit; + return { actual, [operator === "<=" ? "max" : "min"]: limit, pass }; +} + +function makeSample(index) { + const ingress = 1 + (index % 5) * 0.22; + const pipeline = 2.8 + (index % 7) * 0.31; + const persistence = 1.1 + (index % 4) * 0.2; + const pluginIpc = 1.9 + (index % 6) * 0.27; + const rag = index % 3 === 0 ? 4.4 : 0.8 + (index % 5) * 0.18; + const streaming = 1.5 + (index % 8) * 0.24; + const provider = 80 + (index % 13) * 11; + const externalTool = index % 4 === 0 ? 25 + (index % 9) * 3 : 0; + const network = 8 + (index % 10) * 1.7; + const overhead = ingress + pipeline + persistence + pluginIpc + rag + streaming; + const external = provider + externalTool + network; + const total = overhead + external; + return { + index, + segments_ms: { + ingress, + pipeline, + persistence, + plugin_ipc: pluginIpc, + rag, + streaming, + provider, + external_tool: externalTool, + network, + }, + langbot_overhead_ms: Number(overhead.toFixed(3)), + external_latency_ms: Number(external.toFixed(3)), + e2e_latency_ms: Number(total.toFixed(3)), + accounting_gap_ms: Number((total - external - overhead).toFixed(6)), + }; +} + +async function main() { + const root = resolve(env.LBS_ROOT || process.cwd()); + const caseId = "langbot-overhead-accounting-contract"; + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + + const startedAt = new Date(); + const sampleCount = Number(env.LANGBOT_PERF_CONTRACT_SAMPLES || "80"); + const overheadP95BudgetMs = Number(env.LANGBOT_PERF_OVERHEAD_P95_MS || "25"); + const samples = Array.from({ length: sampleCount }, (_, index) => makeSample(index)); + const overheads = samples.map((sample) => sample.langbot_overhead_ms); + const e2e = samples.map((sample) => sample.e2e_latency_ms); + const external = samples.map((sample) => sample.external_latency_ms); + const gaps = samples.map((sample) => Math.abs(sample.accounting_gap_ms)); + const memory = process.memoryUsage(); + + const metrics = { + probe: caseId, + sample_count: sampleCount, + langbot_overhead_ms: stats(overheads), + e2e_latency_ms: stats(e2e), + external_latency_ms: stats(external), + accounting_gap_max_ms: Number(Math.max(...gaps).toFixed(6)), + samples, + }; + const thresholds = { + sample_count: threshold(sampleCount, 50, ">="), + langbot_overhead_p95_ms: threshold(metrics.langbot_overhead_ms.p95, overheadP95BudgetMs, "<="), + accounting_gap_max_ms: threshold(metrics.accounting_gap_max_ms, 0.001, "<="), + }; + const status = Object.values(thresholds).every((item) => item.pass) ? "pass" : "fail"; + const metricsPath = join(evidenceDir, "metrics.json"); + const thresholdsPath = join(evidenceDir, "thresholds.json"); + const resourceLogPath = join(evidenceDir, "resource-log.json"); + const automationResultPath = join(evidenceDir, "automation-result.json"); + const resultPath = join(evidenceDir, "result.json"); + + await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8"); + await writeFile(thresholdsPath, `${JSON.stringify(thresholds, null, 2)}\n`, "utf8"); + await writeFile(resourceLogPath, `${JSON.stringify({ memory, pid: process.pid }, null, 2)}\n`, "utf8"); + + const finishedAt = new Date(); + const result = { + source: "automation", + case_id: caseId, + run_id: runId, + status, + reason: status === "pass" + ? "Overhead accounting contract passed all thresholds." + : "Overhead accounting contract breached one or more thresholds.", + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: finishedAt.toISOString(), + finished_at_local: localIsoWithOffset(finishedAt), + duration_ms: finishedAt.getTime() - startedAt.getTime(), + metrics_summary: { + sample_count: metrics.sample_count, + langbot_overhead_p95_ms: metrics.langbot_overhead_ms.p95, + e2e_latency_p95_ms: metrics.e2e_latency_ms.p95, + external_latency_p95_ms: metrics.external_latency_ms.p95, + accounting_gap_max_ms: metrics.accounting_gap_max_ms, + }, + thresholds_summary: thresholds, + artifacts: { + metrics_json: metricsPath, + thresholds_json: thresholdsPath, + resource_log_json: resourceLogPath, + automation_result_json: automationResultPath, + result_json: resultPath, + }, + evidence_collected: ["metrics", "resource_log", "filesystem"], + }; + + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultPath, resultText, "utf8"); + await writeFile(resultPath, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + exit(status === "pass" ? 0 : 1); +} + +await main(); diff --git a/skills/skills/langbot-testing/probes/lib/fake-provider-timing.mjs b/skills/skills/langbot-testing/probes/lib/fake-provider-timing.mjs new file mode 100755 index 0000000..b383b26 --- /dev/null +++ b/skills/skills/langbot-testing/probes/lib/fake-provider-timing.mjs @@ -0,0 +1,134 @@ +export function summarizeFakeProviderState(state) { + if (!state) return null; + const recentRequests = Array.isArray(state.recent_requests) ? state.recent_requests : []; + const chatRequests = recentRequests.filter((request) => String(request?.path || "").includes("/chat/completions")); + const successfulRequests = chatRequests.filter((request) => request?.status === "ok"); + const faultRequests = chatRequests.filter((request) => ( + request?.should_fail === true + || request?.status === "http_fault" + || (Number.isFinite(request?.http_status) && request.http_status >= 400) + )); + + return { + status: state.status || "unknown", + url: state.url || "", + request_count: Number.isFinite(state.request_count) ? state.request_count : recentRequests.length, + recent_request_count: recentRequests.length, + chat_request_count: chatRequests.length, + fault_count: faultRequests.length, + streamed_request_count: chatRequests.filter((request) => request?.stream === true).length, + duration_ms: stats(chatRequests.map((request) => numberOrNull(request?.duration_ms)).filter(Number.isFinite)), + successful_duration_ms: stats(successfulRequests.map((request) => numberOrNull(request?.duration_ms)).filter(Number.isFinite)), + first_chunk_ms: stats(successfulRequests.map((request) => numberOrNull(request?.first_chunk_ms)).filter(Number.isFinite)), + first_content_chunk_ms: stats(successfulRequests.map((request) => numberOrNull(request?.first_content_chunk_ms)).filter(Number.isFinite)), + content_chunk_count: stats(successfulRequests.map((request) => numberOrNull(request?.content_chunk_count)).filter(Number.isFinite)), + config: state.config || {}, + }; +} + +export function buildProviderTimingMetrics(samples, state) { + const recentRequests = Array.isArray(state?.recent_requests) ? state.recent_requests : []; + const byExpectedText = new Map(); + for (const request of recentRequests) { + const expected = String(request?.expected_text || ""); + if (!expected) continue; + if (!byExpectedText.has(expected)) byExpectedText.set(expected, []); + byExpectedText.get(expected).push(request); + } + + const segments = []; + const missingExpectedText = []; + for (const sample of samples) { + const expected = String(sample?.expected_text || ""); + if (!expected) continue; + const request = (byExpectedText.get(expected) || []).shift(); + if (!request) { + missingExpectedText.push(expected); + continue; + } + const segment = buildTimingSegment(sample, request); + if (segment) segments.push(segment); + } + + const values = (key) => segments.map((segment) => numberOrNull(segment[key])).filter(Number.isFinite); + return { + matched_request_count: segments.length, + missing_provider_match_count: missingExpectedText.length, + missing_expected_text: missingExpectedText.slice(0, 20), + send_to_provider_start_ms: stats(values("send_to_provider_start_ms")), + provider_duration_ms: stats(values("provider_duration_ms")), + provider_finish_to_ws_final_ms: stats(values("provider_finish_to_ws_final_ms")), + langbot_overhead_estimate_ms: stats(values("langbot_overhead_estimate_ms")), + e2e_minus_provider_ms: stats(values("e2e_minus_provider_ms")), + provider_first_content_to_ws_first_content_ms: stats(values("provider_first_content_to_ws_first_content_ms")), + segments, + }; +} + +function buildTimingSegment(sample, request) { + const sentEpochMs = numberOrNull(sample.sent_epoch_ms); + const finishedEpochMs = numberOrNull(sample.finished_epoch_ms); + const providerStartedEpochMs = numberOrNull(request.started_epoch_ms); + const providerFinishedEpochMs = numberOrNull(request.finished_epoch_ms); + const providerFirstContentEpochMs = numberOrNull(request.first_content_chunk_epoch_ms); + const wsFirstContentEpochMs = numberOrNull(sample.first_assistant_content_epoch_ms); + const responseDurationMs = numberOrNull(sample.response_duration_ms); + const providerDurationMs = numberOrNull(request.duration_ms); + + const sendToProviderStartMs = finiteDelta(providerStartedEpochMs, sentEpochMs); + const providerFinishToWsFinalMs = finiteDelta(finishedEpochMs, providerFinishedEpochMs); + const e2eMinusProviderMs = Number.isFinite(responseDurationMs) && Number.isFinite(providerDurationMs) + ? rounded(responseDurationMs - providerDurationMs) + : null; + const overheadEstimateMs = Number.isFinite(sendToProviderStartMs) && Number.isFinite(providerFinishToWsFinalMs) + ? rounded(sendToProviderStartMs + providerFinishToWsFinalMs) + : e2eMinusProviderMs; + + return { + sample_index: sample.index, + pipeline_label: sample.pipeline_label || "", + expected_text: sample.expected_text || "", + provider_request_id: request.id || "", + provider_request_number: request.request_number ?? null, + response_duration_ms: responseDurationMs, + provider_duration_ms: providerDurationMs, + send_to_provider_start_ms: sendToProviderStartMs, + provider_finish_to_ws_final_ms: providerFinishToWsFinalMs, + langbot_overhead_estimate_ms: overheadEstimateMs, + e2e_minus_provider_ms: e2eMinusProviderMs, + provider_first_content_to_ws_first_content_ms: finiteDelta(wsFirstContentEpochMs, providerFirstContentEpochMs), + provider_status: request.status || "", + provider_http_status: request.http_status ?? null, + }; +} + +function finiteDelta(left, right) { + return Number.isFinite(left) && Number.isFinite(right) ? rounded(left - right) : null; +} + +export function stats(values) { + if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 }; + return { + min: rounded(Math.min(...values)), + p50: percentile(values, 50), + p95: percentile(values, 95), + p99: percentile(values, 99), + max: rounded(Math.max(...values)), + }; +} + +export function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1); + return rounded(sorted[index]); +} + +export function rounded(value) { + return Number(value.toFixed(3)); +} + +function numberOrNull(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} diff --git a/skills/skills/langbot-testing/probes/pytest-probe.mjs b/skills/skills/langbot-testing/probes/pytest-probe.mjs new file mode 100755 index 0000000..db98361 --- /dev/null +++ b/skills/skills/langbot-testing/probes/pytest-probe.mjs @@ -0,0 +1,222 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, delimiter, join, resolve } from "node:path"; +import { env } from "node:process"; + +function loadEnvDefaults(root) { + for (const path of [join(root, "skills/.env"), join(root, "skills/.env.local")]) { + if (!existsSync(path)) continue; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf("="); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + if (env[key]) continue; + env[key] = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + } + } +} + +function timestampSlug(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, ""); +} + +function localIsoWithOffset(date = new Date()) { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absolute = Math.abs(offsetMinutes); + const pad = (value) => String(value).padStart(2, "0"); + return [ + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`, + `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`, + ].join(""); +} + +function resolveFromRoot(root, value) { + if (!value) return ""; + return resolve(root, value); +} + +function truncate(text, maxLength = 12000) { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)}\n...[truncated ${text.length - maxLength} chars]`; +} + +function exitCode(status) { + if (status === "pass") return 0; + if (status === "blocked" || status === "env_issue") return 2; + return 1; +} + +async function runProcess(command, timeoutMs, childEnv) { + return await new Promise((resolveDone) => { + const child = spawn(command.executable, command.args, { + cwd: command.cwd, + env: childEnv, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + setTimeout(() => { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + }, 5000).unref(); + }, timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error, timedOut, status: null, signal: null }); + }); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolveDone({ stdout, stderr, error: null, timedOut, status, signal }); + }); + }); +} + +export async function runPytestProbe({ + caseId, + repoEnvKey, + defaultRepo, + pythonPathEnvKeys = [], + defaultPythonPaths = [], + testTargets, + description, + timeoutMs, +}) { + const root = resolve(env.LBS_ROOT || process.cwd()); + loadEnvDefaults(root); + const resolvedTimeoutMs = Number(timeoutMs || env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "180000"); + + const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`; + const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); + await mkdir(evidenceDir, { recursive: true }); + const uvCacheDir = env.UV_CACHE_DIR || join(evidenceDir, ".uv-cache"); + await mkdir(uvCacheDir, { recursive: true }); + + const startedAt = new Date(); + const repoPath = resolveFromRoot(root, env[repoEnvKey] || defaultRepo); + const pythonPaths = [ + ...pythonPathEnvKeys.map((key) => env[key]).filter(Boolean), + ...defaultPythonPaths, + ].map((value) => resolveFromRoot(root, value)); + const automationResultJson = join(evidenceDir, "automation-result.json"); + const stdoutLog = join(evidenceDir, "pytest-stdout.log"); + const stderrLog = join(evidenceDir, "pytest-stderr.log"); + const resultJson = join(evidenceDir, "result.json"); + const command = { + executable: "rtk", + args: ["uv", "run", "pytest", "-q", ...testTargets], + cwd: repoPath, + }; + const result = { + source: "automation", + probe: "pytest", + case_id: caseId, + run_id: runId, + description, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + duration_ms: 0, + status: "fail", + reason: "", + repo_env_key: repoEnvKey, + repo_path: repoPath, + python_paths: pythonPaths, + test_targets: testTargets, + command, + timeout_ms: resolvedTimeoutMs, + uv_cache_dir: uvCacheDir, + exit_status: null, + signal: null, + evidence: { + pytest_stdout_log: stdoutLog, + pytest_stderr_log: stderrLog, + automation_result_json: automationResultJson, + result_json: resultJson, + }, + evidence_collected: ["filesystem"], + }; + + await writeFile(stdoutLog, "", "utf8"); + await writeFile(stderrLog, "", "utf8"); + + try { + if (!existsSync(repoPath)) { + result.status = "env_issue"; + result.reason = `${repoEnvKey || "repo"} did not resolve to an existing directory: ${repoPath}`; + } else { + const missingTargets = testTargets.filter((target) => !existsSync(join(repoPath, target.split("::")[0]))); + if (missingTargets.length > 0) { + result.status = "env_issue"; + result.reason = `pytest target file(s) not found in ${basename(repoPath)}: ${missingTargets.join(", ")}`; + } else { + const childEnv = { ...process.env, UV_CACHE_DIR: uvCacheDir }; + if (pythonPaths.length > 0) { + childEnv.PYTHONPATH = [pythonPaths.join(delimiter), childEnv.PYTHONPATH].filter(Boolean).join(delimiter); + } + const proc = await runProcess(command, resolvedTimeoutMs, childEnv); + result.exit_status = proc.status; + result.signal = proc.signal; + await writeFile(stdoutLog, truncate(proc.stdout), "utf8"); + await writeFile(stderrLog, truncate(proc.stderr), "utf8"); + + if (proc.error) { + result.status = "env_issue"; + result.reason = `Failed to start pytest command '${command.executable}': ${proc.error.message}`; + } else if (proc.timedOut) { + result.status = "fail"; + result.reason = `pytest timed out after ${resolvedTimeoutMs}ms. See ${stdoutLog} and ${stderrLog}.`; + } else if (proc.status === 0) { + result.status = "pass"; + result.reason = `pytest passed for ${testTargets.join(", ")}.`; + } else if (/command not found|no such file or directory|executable file not found/i.test(`${proc.stdout}\n${proc.stderr}`)) { + result.status = "env_issue"; + result.reason = `pytest command could not run in ${repoPath}. See ${stdoutLog} and ${stderrLog}.`; + } else { + result.status = "fail"; + result.reason = `pytest exited with status ${proc.status}. See ${stdoutLog} and ${stderrLog}.`; + } + } + } + } catch (error) { + result.status = "fail"; + result.reason = error instanceof Error ? error.message : String(error); + } finally { + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + result.duration_ms = finishedAt.getTime() - startedAt.getTime(); + const resultText = `${JSON.stringify(result, null, 2)}\n`; + await writeFile(automationResultJson, resultText, "utf8"); + await writeFile(resultJson, resultText, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } + + process.exit(exitCode(result.status)); +} diff --git a/skills/skills/langbot-testing/references/agent-runner-qa-workflow.md b/skills/skills/langbot-testing/references/agent-runner-qa-workflow.md new file mode 100644 index 0000000..76e62fa --- /dev/null +++ b/skills/skills/langbot-testing/references/agent-runner-qa-workflow.md @@ -0,0 +1,112 @@ +# AgentRunner QA Workflow + +Use this workflow when an agent finishes AgentRunner-related code and enters a +test phase. + +## Order + +1. Inspect changed repositories with `rtk git status --short` and + `rtk git diff --stat`. + Also run `rtk git diff --name-only` and, when untracked files are present, + `rtk git ls-files --others --exclude-standard` so new probes, fixtures, and + cases are not missed. +2. Choose the smallest relevant checks: + - Fast contract probes first. + - Targeted repo tests second. + - Browser cases only after contract probes are green or clearly classified. +3. Start from saved assets: + - `rtk bin/lbs test recommend` + - `rtk bin/lbs suite plan agent-runner-release-gate` + - `rtk bin/lbs case list --tag agent-runner` + - `rtk bin/lbs trouble search agent-runner` +4. Run probes before browser cases: + - `rtk bin/lbs test run agent-runner-fixture-contract --dry-run` + - `rtk bin/lbs test run agent-runner-live-install --dry-run` when a local LangBot + backend is available and installing the QA fixture is acceptable. + - `rtk bin/lbs test run agent-runner-qa-debug-chat --dry-run` when WebUI live + execution needs deterministic coverage without a model provider. This + case runs its setup automation first: install the QA AgentRunner fixture, + create/update the QA pipeline, write the case-specific pipeline env, then + execute Debug Chat. + - `rtk bin/lbs test run agent-runner-ledger-invariants --dry-run` + - `rtk bin/lbs test run agent-runner-ledger-contention --dry-run` + - `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run` + - `rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run` when async DB + readiness is known good. +5. Run browser release paths only after environment preflight: + - `rtk bin/lbs test run agent-runner-release-preflight --dry-run` + - `rtk bin/lbs suite start agent-runner-release-gate --run-id ` + +Remove `--dry-run` only after readiness and `manual_check` preconditions are +confirmed for the current test instance. + +## Diff Triage + +Use changed paths to choose checks. Start with the most specific row that +matches the diff, then add adjacent rows only when shared contracts changed. +For the common case, run `rtk bin/lbs test recommend` first and use this table +only to review or adjust the generated list. + +| Changed Path or Area | First Checks | Add Browser Case When | +| --- | --- | --- | +| `LangBot/src/langbot/pkg/agent/runner/*`, `tests/unit_tests/agent/test_result_normalizer.py`, protocol/result/context/resource builders | `rtk bin/lbs test run agent-runner-fixture-contract --dry-run`; `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted LangBot unit tests for touched files | Result shape, user-visible runner output, or Debug Chat delivery changed: add `pipeline-debug-chat` or `local-agent-basic-debug-chat`. | +| `LangBot/src/langbot/pkg/entity/persistence/agent_run.py`, `run_journal.py`, run ledger store/API/auth tests, claim/lease/status code | `rtk bin/lbs test run agent-runner-ledger-invariants --dry-run`; `rtk bin/lbs test run agent-runner-ledger-stress --dry-run`; `rtk bin/lbs test run agent-runner-ledger-contention --dry-run`; `rtk bin/lbs test run agent-runner-async-db-readiness --dry-run` before `rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run` | Debug Chat run lifecycle, resume, or visible completion changed: add `local-agent-basic-debug-chat`. | +| `langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/agent_runner/*`, `api/proxies/agent_run_api.py`, runtime pull handlers, plugin manager/runtime IO | `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run`; `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted SDK pytest | Runtime delivery or tool-call surface changed: add `agent-runner-release-preflight`, then `local-agent-basic-debug-chat`. | +| `langbot-agent-runner/*/components/agent_runner/*`, external runner daemon/client code, ACP/Codex/Claude runner command wrappers | Repo-local targeted tests; `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run`; `rtk bin/lbs test run agent-runner-release-preflight --dry-run` | ACP or external coding runner behavior changed: add `acp-agent-runner-debug-chat`. | +| Prompt preprocessing, effective prompt, pipeline AI config, runner binding/default runner migration | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted LangBot pipeline/agent tests | The runner reads host-provided prompt or saved runner config: add `local-agent-effective-prompt-debug-chat`. | +| Context window, transcript, history/event state, compaction, checkpoint/steering | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted LangBot agent state/context tests | Multi-turn memory, compaction, or steering behavior changed: add `local-agent-context-compaction-debug-chat` and, for steering-specific changes, `local-agent-steering-debug-chat`. | +| Plugin tool authorization, host tool listing, MCP tool bridge, function-call conversion | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted plugin/MCP/tool tests | Tool execution is user-visible: add `local-agent-plugin-tool-call-debug-chat`; for MCP-specific changes add `mcp-stdio-register` then `mcp-stdio-tool-call`. | +| RAG context injection, knowledge base retrieval, resource packaging | Targeted LangBot RAG/resource tests; `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run` when runner input shape changed | Runner answer should include retrieved context: add `langrag-kb-retrieve` and `local-agent-rag-debug-chat`. | +| Streaming/non-streaming adapter, provider message conversion, image or multimodal payloads | `rtk bin/lbs test run agent-runner-behavior-matrix --dry-run`; targeted provider/pipeline tests | User-visible transport changed: add `local-agent-basic-debug-chat`, `local-agent-nonstreaming-debug-chat`, or `local-agent-multimodal-debug-chat` matching the diff. | +| Only `langbot-skills` cases, probes, fixtures, references, schemas, or CLI planning code | `rtk bin/lbs validate`; relevant `rtk bin/lbs test plan ` or `rtk bin/lbs test run --dry-run` if supported | Do not run browser cases unless the edited asset itself changes the browser path being validated. | + +If a diff crosses more than two rows or touches protocol, ledger, SDK runtime, +and browser-visible runner behavior together, stop trying to hand-pick a tiny +set and use `agent-runner-release-gate`. + +## Recommended Minimal Sets + +- Protocol or normalization only: `agent-runner-fixture-contract`, + `agent-runner-behavior-matrix`, plus targeted repo unit tests. +- Ledger persistence only: `agent-runner-ledger-invariants`, + `agent-runner-ledger-stress`, `agent-runner-ledger-contention`, and + `agent-runner-async-db-readiness`; run `agent-runner-ledger-concurrency` only + when async DB readiness passes. +- SDK runtime only: `agent-runner-runtime-chaos` plus targeted SDK pytest. +- Local-agent user path: `agent-runner-release-preflight` then + `local-agent-basic-debug-chat`; add the specific RAG/tool/MCP/context/ + multimodal case only when the diff touches that contract. +- External ACP runner path: `agent-runner-release-preflight` then + `acp-agent-runner-debug-chat`. + +## Asset Rules + +- If a stable product path is missing, add one `cases/*.yaml` file. +- If a non-UI invariant or stress check is missing, add one `mode: probe` case + and one script under `probes/`. +- If the failure repeats, add one `troubleshooting/*.yaml` entry. +- Keep probe scripts deterministic. Prefer stdlib, existing repo tests, and + local fixtures over real provider calls. +- Do not mark a browser case passed from API/curl/probe evidence alone. + +## Result Classification + +- `pass`: declared checks and required evidence passed. +- `fail`: product or contract behavior is wrong. +- `env_issue`: the target could not run because a runtime dependency failed, + such as `aiosqlite.connect()` hanging before Host ledger tests start. +- `blocked`: required pipeline, plugin, credential, or fixture is missing. +- `flaky`: rerun needed because evidence shows transient instability. + +## Maintenance Gate + +After adding or editing QA assets: + +```bash +rtk bin/lbs validate +rtk bin/lbs index +rtk npm test +``` + +Commit only reusable assets and code. Do not commit `reports/evidence/*` +run output. diff --git a/skills/skills/langbot-testing/references/agent-runner-release-gate.md b/skills/skills/langbot-testing/references/agent-runner-release-gate.md new file mode 100644 index 0000000..89a75fc --- /dev/null +++ b/skills/skills/langbot-testing/references/agent-runner-release-gate.md @@ -0,0 +1,172 @@ +# Agent Runner Release Gate + +Use this reference when judging whether runner externalization is release-ready. The goal is not to enumerate every possible prompt. The gate covers product abilities and trust boundaries with deterministic normal-path cases, then leaves rare negative branches to unit and contract tests. + +## Coverage Strategy + +Treat the release gate as five layers: + +| Layer | Purpose | Primary Assets | +| --- | --- | --- | +| Contract gate | Protocol, SDK, auth, stores, and plugin handler behavior without a browser. | `agent-runner-fixture-contract`, `agent-runner-behavior-matrix`, `agent-runner-ledger-invariants`, `agent-runner-ledger-stress`, `agent-runner-ledger-contention`, `agent-runner-runtime-chaos`, `agent-runner-ledger-concurrency`, plus unit and contract tests in touched repos. | +| Environment preflight | Prove the selected live instance is configured for the full gate before expensive browser cases start. | `agent-runner-live-install`, `agent-runner-qa-debug-chat`, `agent-runner-release-preflight`. | +| Fixture gate | Prove deterministic plugin, RAG, multimodal, and MCP fixtures are installed and registered. | `plugin-e2e-smoke`, `langrag-kb-retrieve`, `mcp-stdio-register`. | +| Local-agent capability gate | Prove normal user-facing local-agent paths through WebUI Debug Chat. | `local-agent-gate` cases. | +| External harness gate | Prove ACP can execute an external coding agent through the WebUI Debug Chat path. | `acp-agent-runner-debug-chat`. | + +Run the full release gate with: + +```bash +rtk bin/lbs suite run agent-runner-release-gate --dry-run --json +rtk bin/lbs suite plan agent-runner-release-gate +rtk bin/lbs suite start agent-runner-release-gate --run-id agent-runner-release- +``` + +Confirm readiness and `manual_check` preconditions before removing `--dry-run` +or running the generated per-case commands. Then finish with: + +```bash +rtk bin/lbs suite report agent-runner-release-gate --evidence-dir reports/evidence/agent-runner-release- +``` + +For a quick early blocker check, run: + +```bash +rtk bin/lbs test run agent-runner-release-preflight --dry-run +``` + +For the code-level AgentRunner probes, run: + +```bash +rtk bin/lbs test run agent-runner-behavior-matrix --dry-run +rtk bin/lbs test run agent-runner-fixture-contract --dry-run +rtk bin/lbs test run agent-runner-ledger-invariants --dry-run +rtk bin/lbs test run agent-runner-ledger-stress --dry-run +rtk bin/lbs test run agent-runner-ledger-contention --dry-run +rtk bin/lbs test run agent-runner-async-db-readiness --dry-run +rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run +rtk bin/lbs test run agent-runner-runtime-chaos --dry-run +rtk bin/lbs test run agent-runner-live-install --dry-run +rtk bin/lbs test run agent-runner-qa-debug-chat --dry-run +``` + +`agent-runner-behavior-matrix` executes the deterministic behavior fixture at +`fixtures/agent-runner/qa-runner-behaviors.json` through Host result +normalization. It covers normal completed output, streaming output, empty output, +malformed payloads, and controlled failure output without a model provider. + +`agent-runner-fixture-contract` imports the source fixture at +`fixtures/plugins/qa-agent-runner` and executes normal, streaming, and +controlled-failure paths with SDK protocol entities. It proves the deterministic +QA runner source is usable before a live installation/browser case uses it. +`bin/lbs fixture check` also verifies the matching +`fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg` package is +present and is a zip package. + +`agent-runner-live-install` uploads that package to a local LangBot backend and +checks that `qa/agent-runner` is installed and +`plugin:qa/agent-runner/default` appears in pipeline runner metadata. It is an +API integration gate, not a Debug Chat execution proof. + +`agent-runner-qa-debug-chat` is the deterministic live execution proof. It uses +a pipeline created by `scripts/e2e/ensure-qa-agent-runner-pipeline.mjs` and +expects Debug Chat to return `QA_AGENT_RUNNER_OK:` through +`plugin:qa/agent-runner/default`. + +`agent-runner-ledger-invariants` is the fast Host ledger probe. It uses +synchronous SQLite and checks run status sets, terminal status validation, +ledger table/index DDL, and a minimal insert/read path without a browser or +`aiosqlite`. + +`agent-runner-ledger-stress` is a fast deterministic stress baseline. It uses +synchronous SQLite to create 100 queued runs and simulates five runtimes claiming +each run exactly once. It does not replace async/PostgreSQL concurrency tests, +but catches schema and ordering regressions quickly. + +`agent-runner-ledger-contention` is a local write-contention probe. It uses a +file-backed SQLite database, 120 queued runs, and eight worker threads to verify +that each run is claimed exactly once under concurrent writers. It still does +not replace async/PostgreSQL concurrency tests. + +`agent-runner-async-db-readiness` checks whether direct `aiosqlite` startup is +healthy before running async Host ledger pytest probes. + +`agent-runner-ledger-concurrency` is the async Host pytest probe. It exercises +selected run ledger store/API auth tests from `LANGBOT_REPO` or `../LangBot`. +If it times out before any test result and a direct `aiosqlite.connect()` script +also hangs, classify the run with troubleshooting id +`aiosqlite-connect-hangs` instead of treating it as a browser E2E failure. + +`agent-runner-runtime-chaos` runs SDK AgentRunner runtime and pull API handler +tests from `LANGBOT_PLUGIN_SDK_REPO` or `../langbot-plugin-sdk`. +Each probe writes `automation-result.json` and probe logs under +`LBS_EVIDENCE_DIR`. + +## Normal-Path Matrix + +| Product Path | Case Coverage | What It Proves | +| --- | --- | --- | +| Authenticated WebUI session | `webui-login-state`, `agent-runner-release-preflight` | The browser profile can operate the same backend that later cases use. | +| Generic Pipeline Debug Chat | `pipeline-debug-chat` | The WebUI Debug Chat path itself works before runner-specific failures are diagnosed. | +| Deterministic QA runner install | `agent-runner-live-install` | A local `.lbpkg` AgentRunner package can install and register a runner. | +| Deterministic QA runner Debug Chat | `agent-runner-qa-debug-chat` | The installed QA runner executes through WebUI Debug Chat without a model provider. | +| Required runner plugins | `agent-runner-release-preflight` | `langbot/local-agent` and `langbot/acp-agent-runner` are visible to the host. | +| Required QA plugin tool | `plugin-e2e-smoke`, `agent-runner-release-preflight` | The deterministic `qa_plugin_echo` tool is exposed before tool-loop cases start. | +| Knowledge base fixture | `langrag-kb-retrieve`, `local-agent-rag-debug-chat` | LangRAG data is queryable and the runner inserts retrieved context. | +| Effective prompt bridge | `local-agent-effective-prompt-debug-chat` | Host prompt preprocessing reaches the runner. | +| History and compaction | `local-agent-context-compaction-debug-chat` | Runner-owned history budgeting keeps recoverable older context. | +| Streaming LLM | `local-agent-basic-debug-chat` | The default streaming path returns a visible answer. | +| Non-streaming LLM | `local-agent-nonstreaming-debug-chat` | The non-streaming adapter path returns a visible answer. | +| Plugin tool loop | `local-agent-plugin-tool-call-debug-chat` | Function-call capable models can call host plugin tools through authorization. | +| MCP registration | `mcp-stdio-register` | The deterministic stdio MCP server is registered and exposes `qa_mcp_echo`. | +| MCP tool loop | `mcp-stdio-tool-call` | Local-agent can call the registered MCP tool through the same tool loop. | +| Multimodal input | `local-agent-multimodal-debug-chat` | Image upload and structured input reach the runner. | +| Multimodal plus RAG | `local-agent-rag-multimodal-debug-chat` | RAG still works when structured image input is present. | +| ACP external harness execution | `acp-agent-runner-debug-chat` | ACP executes the configured coding agent and returns visible Debug Chat output. | + +## Status Taxonomy + +Use the same final result categories for every case: + +- `pass`: the visible UI behavior and required evidence match the case checks. +- `fail`: the configured product path is reachable, but LangBot or the runner behaves incorrectly. +- `blocked`: the test instance is not configured for this gate, for example missing pipeline, wrong runner id, missing required plugin, or unreachable ACP agent runtime. +- `env_issue`: the runtime dependency is unhealthy, for example backend down, plugin runtime down, Box down, provider route unavailable, invalid API key, or missing model ability in the selected route. +- `flaky`: the path can pass but the run hit a transient network, marketplace, upstream provider, or timing problem that needs a rerun and evidence. + +Do not count `blocked` or `env_issue` as product pass. They are useful release signals because they prevent false confidence. + +## PR Gate + +Before a browser release run, also keep the code-level gate green in the repos touched by the branch: + +```bash +# langbot-agent-runner +rtk uv run pytest -q + +# langbot-plugin-sdk +rtk uv run pytest -q + +# langbot-skills saved AgentRunner probes +rtk bin/lbs test run agent-runner-behavior-matrix --dry-run +rtk bin/lbs test run agent-runner-ledger-invariants --dry-run +rtk bin/lbs test run agent-runner-ledger-stress --dry-run +rtk bin/lbs test run agent-runner-async-db-readiness --dry-run +rtk bin/lbs test run agent-runner-ledger-concurrency --dry-run +rtk bin/lbs test run agent-runner-runtime-chaos --dry-run + +# LangBot, target the touched package first, then broaden if shared behavior changed +rtk uv run pytest -q +``` + +These tests cover field-level protocol conformance, SDK proxy behavior, auth failures, and negative branches that should not depend on a live provider. The browser gate then proves the normal user paths still compose correctly. + +## Release Decision + +For runner externalization, a release candidate is acceptable only when: + +- PR contract tests are green in every touched repo. +- `agent-runner-release-preflight` has no blockers and no environment issues. +- Every case in `agent-runner-release-gate` has a final `result.json`. +- No `pass` result is missing required evidence. +- Any skipped case is explicitly classified as `blocked` or `env_issue` with a concrete owner and follow-up. diff --git a/skills/skills/langbot-testing/references/dify-agent-runner.md b/skills/skills/langbot-testing/references/dify-agent-runner.md new file mode 100644 index 0000000..e576959 --- /dev/null +++ b/skills/skills/langbot-testing/references/dify-agent-runner.md @@ -0,0 +1,48 @@ +# Dify AgentRunner + +Use this reference when validating `langbot/dify-agent` through LangBot WebUI. + +## Prepare Dify + +- Use a Dify Service API key from the target Dify app. Do not print the key in reports. +- For Dify Agent Chat apps, configure LangBot `app-type` as `agent`. +- Dify Agent Chat Service API may reject direct `blocking` mode with `Agent Chat App does not support blocking mode`; use streaming for direct diagnostics. + +## LangBot Configuration + +1. Open `LANGBOT_FRONTEND_URL`. +2. Navigate to `Pipelines` and open the target pipeline. +3. Open `Configuration > AI`. +4. Select runner `Dify`. +5. Configure: + - `Base URL`: usually `https://api.dify.ai/v1` + - `App Type`: `Agent` for Dify Agent Chat apps + - `API Key`: Dify Service API key + - `Base Prompt`: short neutral prompt unless the case needs a specific prompt + - `Timeout`: at least `60` when testing through proxies +6. Save before using Debug Chat. + +## Debug Chat Check + +Send a prompt with a unique sentinel: + +```text +Reply exactly with LANGBOT_DIFY_ and nothing else. +``` + +Pass only when: + +- UI shows a `Bot` message containing the sentinel. +- WebSocket history or DOM inspection confirms the sentinel is in an assistant/bot message, not only in the user message. +- Backend logs show the request completed, for example `HTTP Request: POST https://api.dify.ai/v1/chat-messages "HTTP/1.1 200 OK"` and `Conversation(0) Streaming completed`. + +## Diagnostics + +- `GET /api/v1/pipelines/{uuid}` can confirm the saved runner id is `plugin:langbot/dify-agent/default` and runner config contains `app-type`, `base-url`, and `api-key`. +- Direct Dify streaming API calls are useful only to distinguish invalid Dify credentials from LangBot runner issues. +- If Debug Chat returns `Agent runner execution failed`, inspect backend logs before changing UI settings. + +## Known Failure Signatures + +- `AttributeError: 'ActorContext' object has no attribute 'type'`: runner code is reading old actor fields; see troubleshooting `agent-runner-actor-context-fields`. +- Multiple runner options display as `默认`: component labels are ambiguous; see troubleshooting `ambiguous-runner-default-label`. diff --git a/skills/skills/langbot-testing/references/langrag-knowledge-base.md b/skills/skills/langbot-testing/references/langrag-knowledge-base.md new file mode 100644 index 0000000..00e0fb8 --- /dev/null +++ b/skills/skills/langbot-testing/references/langrag-knowledge-base.md @@ -0,0 +1,72 @@ +# LangRAG Knowledge Base + +Use this reference when validating LangRAG creation, document ingestion, retrieval, and local-agent RAG behavior. + +## Setup + +1. Install `langbot-team/LangRAG` from Marketplace if `/api/v1/knowledge/engines` has no LangRAG engine. +2. Confirm `LANGBOT_BACKEND_URL/api/v1/knowledge/engines` contains plugin id `langbot-team/LangRAG`. +3. Prefer local Chroma embedding for offline/free tests: + - Provider requester: `chroma-embedding` + - Embedding model name: `chroma-all-MiniLM-L6-v2` + +Important: a Chroma embedding entry must exist under `embedding_models`. A model accidentally created as an LLM model will appear in the wrong model selector and will not satisfy LangRAG's embedding-model field. + +## Parser Golden Case + +Use `cases/langrag-parser-golden-e2e.yaml` when validating the LangRAG + GeneralParsers integration on the current master worktree. + +Fixture: + +```text +fixtures/rag/parser-golden.html +``` + +Golden intent: + +- Start LangBot from `LANGBOT_REPO`, which should point at the master worktree for this run. +- Build and install/update local `LANGBOT_RAG_PLUGIN_REPO` and `LANGBOT_PARSER_PLUGIN_REPO`. +- Upload the HTML fixture and select GeneralParsers when the parser chooser is shown. +- Confirm retrieval returns `aurora-parser-rag-9137`, `GeneralParsers`, `LangRAG`, and the Markdown table header `| Parser field | Golden value |`. +- Confirm logs show LangRAG used external pre-parsed content instead of the internal fallback parser. + +Local install pitfall: + +- If GeneralParsers fails while installing `PyMuPDF>=1.24.0`, read `troubleshooting/plugin-dependency-install-offline.yaml`. +- The golden case can continue after the active LangBot master venv can `import fitz` and `python -m pip install --dry-run 'PyMuPDF>=1.24.0'` reports the requirement is satisfied. + +## Browser Flow + +1. Open `LANGBOT_FRONTEND_URL`. +2. Navigate to `Knowledge`. +3. Create a knowledge base. +4. Select engine `LangRAG`. +5. Select embedding model `chroma-all-MiniLM-L6-v2` or another known working embedding model. +6. Keep the index type as `Chunk` for smoke/regression tests. +7. Upload a small sentinel document. +8. Wait until the document row status is `Completed`. +9. Open `Retrieve Test` and query for the sentinel. + +Recommended fixture: + +```text +fixtures/rag/sentinel-doc.txt +``` + +## Pass Criteria + +- The created knowledge base appears in the sidebar. +- The uploaded document reaches `Completed`. +- Retrieve Test returns the uploaded document with the sentinel text. +- Browser console has no unexpected errors. + +## Local-Agent RAG Check + +After retrieval passes: + +1. Open the target pipeline. +2. In `Configuration > AI`, add the knowledge base to `Knowledge Bases`. +3. Save. +4. Open `Debug Chat`. +5. Ask for the sentinel. +6. Confirm the bot response contains the exact sentinel. diff --git a/skills/skills/langbot-testing/references/local-agent-runner-coverage.md b/skills/skills/langbot-testing/references/local-agent-runner-coverage.md new file mode 100644 index 0000000..04495f8 --- /dev/null +++ b/skills/skills/langbot-testing/references/local-agent-runner-coverage.md @@ -0,0 +1,74 @@ +# Local Agent Runner Coverage + +Use this matrix when judging whether the external `langbot/local-agent` plugin still behaves like the old built-in local-agent runner. + +The QA target is end-to-end behavior. UI cases prove the host, SDK, plugin runtime, and WebUI work together. Unit or component tests are still needed for negative branches that are hard to trigger reliably through a live provider. + +## Code Path Basis + +- `LangBot/src/langbot/pkg/agent/runner/context_builder.py` builds the Protocol v1 context from the event envelope: `ctx.input.text`, `ctx.input.contents`, attachments, state, resources, and runtime metadata. +- `LangBot/src/langbot/pkg/agent/runner/pipeline_adapter.py` adapts Pipeline-only fields into `ctx.adapter.extra.prompt`, `ctx.adapter.extra.params`, and optional `ctx.bootstrap.messages`. +- `LangBot/src/langbot/pkg/agent/runner/resource_builder.py` authorizes models, fallback models, rerank models, tools, and knowledge bases for the current run. +- `LangBot/src/langbot/pkg/plugin/handler.py` validates run-scoped model/tool/rerank access and calls the host model provider or tool manager with the current query. +- `langbot-local-agent/components/agent_runner/default.py` selects streaming or non-streaming execution, retrieves RAG context, builds messages, invokes models with fallback, and runs tool loops. +- `langbot-local-agent/pkg/messages.py` prefers the host effective prompt from `ctx.adapter.extra.prompt`, uses `ctx.bootstrap.messages` only as a small bootstrap window, and preserves structured/multimodal input while inserting RAG context. + +TODO: Treat `ctx.adapter.extra.prompt` as a temporary Pipeline bridge for old +local-agent behavior parity. It is not the final answer for how user plugins or +host hooks should influence agent behavior after Pipeline is replaced. + +## Minimum UI Gate + +These browser cases are the minimum gate for a local-agent migration check: + +| Case | Path Covered | Expected Behavior | +| --- | --- | --- | +| `local-agent-basic-debug-chat` | Streaming LLM invocation with effective host context | Bot returns deterministic `OK`; backend logs streaming completion. | +| `local-agent-effective-prompt-debug-chat` | PromptPreProcessing and host effective prompt handoff through `ctx.adapter.extra.prompt` | Bot returns `PROMPT_PREPROCESS_OK` from the fixture prompt probe. | +| `local-agent-context-compaction-debug-chat` | Runner-owned context budgeting and old-history compaction | Automation temporarily shrinks the runner context window, sends multi-turn Debug Chat history, and the bot still recovers the older sentinel. | +| `local-agent-rag-debug-chat` | Knowledge-base authorization, retrieval, and RAG prompt insertion | Bot returns the KB sentinel, not a generic answer. | +| `mcp-stdio-tool-call` | MCP stdio discovery, tool detail, model function calling, and tool execution | Bot returns `qa_mcp_echo:` and backend logs the MCP tool call. | +| `local-agent-plugin-tool-call-debug-chat` | Plugin tool discovery, tool detail, model function calling, and tool execution | Bot returns `qa-plugin-smoke:` and backend logs the plugin tool call. | +| `local-agent-steering-debug-chat` | Host steering claim, runner pull at turn boundary, and follow-up injection during an active tool loop | Two user messages produce one assistant response containing the steering sentinel. | +| `local-agent-multimodal-debug-chat` | Image upload, structured input contents, and multimodal runner consumption | UI shows uploaded image and bot returns `IMAGE_OK`; backend receives an image input. | +| `local-agent-rag-multimodal-debug-chat` | RAG insertion while structured image input is present | UI shows uploaded image, bot returns the KB sentinel, and backend logs the same request with `[Image]`. | +| `local-agent-nonstreaming-debug-chat` | Host non-streaming adapter path and runner non-streaming invocation | Bot returns `NONSTREAM_OK`; backend completes without the streaming-completed path. | + +## Full Coverage Matrix + +| Area | How To Cover | Pass Signal | +| --- | --- | --- | +| Effective prompt | Use the `qa-plugin-smoke` prompt probe and send `qa-effective-prompt`. | The answer follows `query.prompt.messages` and returns `PROMPT_PREPROCESS_OK`; plugin-local fallback config prompt is not used when host prompt exists. | +| Current text input | Send a deterministic text-only Debug Chat prompt. | `ctx.input.text` becomes the user text and the bot answers the text request. | +| Structured input contents | Upload an image with text in Debug Chat. | User message shows the image; backend log or request payload contains image content; model can acknowledge it. | +| Multimodal plus RAG | Run `local-agent-rag-multimodal-debug-chat`. | RAG sentinel is still retrievable and the image is not dropped from the user message; exact image-preservation inside the model message is covered by unit tests. | +| History and context compaction | Run `local-agent-context-compaction-debug-chat` with a small temporary `context-window-tokens` budget. | The runner compacts older history into `` and the final answer still recovers the older sentinel from the compacted context. | +| Streaming model invocation | Enable Debug Chat streaming and ask for `OK`. | UI receives incremental bot output and backend logs streaming completion. | +| Non-streaming model invocation | Disable Debug Chat streaming or use a non-streaming adapter path. | UI receives a final bot message and backend logs a normal response completion. | +| Model fallback before first chunk | Configure a failing primary and working fallback, preferably with a controlled test provider. | First model failure does not fail the run; fallback model produces the final answer. | +| Failure after streaming commit | Use a controlled provider that emits one chunk and then fails. | Runner reports a terminal run failure and does not fallback after partial output. | +| No authorized model | Clear model config or configure a model not in run resources. | Runner returns `runner.no_model` instead of calling an unauthorized model. | +| MCP tool call | Use `qa-local-stdio` and `qa_mcp_echo`. | Bot returns the exact `qa_mcp_echo:` result; `/api/v1/tools` contains `qa_mcp_echo`. | +| Plugin tool call | Install a fixture plugin exposing a deterministic tool and bind it to the pipeline. | Runner lists the plugin tool and can call it through the same tool loop as MCP tools. | +| Run steering | Use `local-agent-steering-debug-chat` with the fixture `qa_plugin_sleep` tool. | A follow-up sent while the sleep tool keeps the run active is claimed into the same run: two user messages, one assistant response, sentinel included. | +| Tool errors | Make the model request an unauthorized tool or invalid arguments in a controlled unit/component test. | Tool result contains an error message and the run does not bypass authorization. | +| Tool iteration limit | Use a controlled model/tool fixture that repeatedly requests more tool calls. | Runner stops with `runner.tool_loop_limit` at the configured limit. | +| Knowledge retrieval | Bind a KB containing a unique sentinel. | Bot returns the sentinel and backend logs LangRAG retrieval. | +| Legacy `knowledge-base` config | Load a pipeline config using the old single-KB field. | Runner still retrieves from the KB. | +| Rerank | Configure `rerank-model` and `rerank-top-k` with a working rerank provider. | Retrieval order follows rerank output; unauthorized or failing rerank falls back to original retrieval order. | +| Remove-think | Enable output `remove-think` on a model that emits think tags. | Final visible output omits think content on both streaming and non-streaming paths. | +| Model extra args | Configure provider/model extra args and run Debug Chat. | Host merges persisted model extra args before provider invocation. | +| Query-aware tools | Call a tool that needs the current Query/session context. | Tool receives the active query and behaves the same as it did under the built-in runner. | +| Params filtering | Add public and secret-like variables before the run. | Public params are visible to the runner; `_internal`, token, key, password, and credential fields are filtered. | +| Actor/session context | Run through Debug Chat and at least one platform adapter path. | `conversation`, `actor`, `subject`, and state scopes contain stable IDs for the current launcher and sender. | + +## Reporting Rules + +When reporting a local-agent QA result, separate these categories: + +- `Passed by UI`: path was verified through browser-visible behavior and backend/network evidence. +- `Covered by unit/component tests`: path is deterministic in tests but not practical as a live UI case. +- `Not covered`: path still needs a fixture or provider setup. +- `Environment issue`: provider channel, proxy, OAuth, or external marketplace/network problem outside the runner path. + +Do not mark the whole runner healthy based only on a single text Debug Chat response. diff --git a/skills/skills/langbot-testing/references/local-agent-runner.md b/skills/skills/langbot-testing/references/local-agent-runner.md new file mode 100644 index 0000000..63a2b7e --- /dev/null +++ b/skills/skills/langbot-testing/references/local-agent-runner.md @@ -0,0 +1,75 @@ +# Local Agent Runner + +Use this reference when validating the pluginized `langbot/local-agent` runner through the WebUI. + +The goal is behavior parity with the old built-in local-agent runner. The code does not need to be identical, but the visible behavior should match: effective prompt, current input, history, model selection and fallback, tool calling, knowledge retrieval, multimodal input, streaming and non-streaming output all have to reach the runner through the host and SDK. + +For path-by-path coverage, read [Local Agent Runner Coverage](local-agent-runner-coverage.md). + +## Main Surface + +- Open `LANGBOT_FRONTEND_URL`. +- Navigate to `Pipelines`. +- Open the target pipeline. +- In `Configuration > AI`, select runner `Default`. +- Configure: + - `Model`: an LLM model that is known to answer Debug Chat. + - `Knowledge Bases`: only when validating RAG behavior. + - `Rerank Model`: leave `None` unless the case explicitly tests reranking. +- Save the pipeline before using Debug Chat. + +## Debug Chat Checks + +Use `Debug Chat` as the primary local-agent validation path. + +For a basic runner check, send a deterministic prompt such as: + +```text +请只回复 OK,用于前端调试测试。 +``` + +For a RAG check, bind a knowledge base containing a unique sentinel and ask for that sentinel. + +For a tool check, ensure the target tool is visible in `/api/v1/tools`, then ask the runner to call it with deterministic input. +Avoid simultaneous fixtures with the same visible tool name. The current MCP fixture uses `qa_mcp_echo` and the plugin fixture uses `qa_plugin_echo` for unambiguous runner checks. If a run returns `qa-plugin-smoke:` during an MCP case, it exercised a plugin tool or stale registration, not the MCP tool. +If the direct MCP fixture passes but `/api/v1/tools` still shows the old MCP name, run `node scripts/e2e/mcp-stdio-register.mjs` to refresh `qa-local-stdio` before rerunning Debug Chat. + +For a multimodal check, upload a small image and ask for a deterministic acknowledgement. Prefer the bundled 64x64 red-square fixture over a 1x1 image because some model providers reject tiny images before the runner path is exercised. + +For a non-streaming check, disable the Debug Chat stream switch before sending the prompt. + +## Timeout And Tool Regression Checks + +When validating runner timeout or SDK deadline changes, confirm `Configuration > AI` renders the runner timeout field and that the saved value is the one used by the run context. The default local-agent timeout is expected to be `300` seconds unless the pipeline overrides it. + +Pair a basic Debug Chat run with a deterministic plugin tool call, for example `qa_plugin_echo`, then correlate the browser response with backend logs. A healthy run shows the tool call started and completed, and does not emit `runner.timeout`, `Action ... timed out`, `All models failed`, `Traceback`, or unexpected `ERROR` lines for the same request. + +## Minimum Regression Gate + +Run these cases before saying the pluginized local-agent behavior is healthy: + +- `local-agent-basic-debug-chat`: basic streaming model invocation. +- `local-agent-effective-prompt-debug-chat`: host effective prompt after PromptPreProcessing reaches the runner. +- `local-agent-rag-debug-chat`: LangRAG retrieval reaches the runner and affects the answer. +- `mcp-stdio-tool-call`: MCP tool discovery and local-agent tool loop. +- `local-agent-plugin-tool-call-debug-chat`: plugin tool discovery and local-agent tool loop. +- `local-agent-multimodal-debug-chat`: uploaded image reaches `ctx.input.contents`. +- `local-agent-rag-multimodal-debug-chat`: RAG retrieval still works when the same user message carries an image. +- `local-agent-nonstreaming-debug-chat`: runner works when the host adapter cannot or should not stream. + +## Pass Criteria + +- The UI shows the user message and a bot response. +- Console has no unexpected React/runtime errors. +- Backend logs show the debug-chat request completed rather than timing out in plugin/runtime calls. +- When testing RAG or tools, the answer contains the expected sentinel or tool result, not a generic explanation. +- Provider errors such as `model_not_found` or `no available channel` are environment/model availability failures. They do not prove MCP, RAG, or local-agent runner failure unless the same model works outside the tested runner path. +- A model that works for basic streaming may still fail for tool-call, multimodal, or non-streaming request shapes. Treat `runner.llm_error` and `runner.tool_loop_error` with `model_not_found`, `invalid api key`, or upstream saturation as environment/model-route failures until retested with a known-good model for that exact shape. + +## Diagnostic API + +API checks are diagnostic only: + +- `GET /api/v1/pipelines/{uuid}` confirms saved runner config. +- `GET /api/v1/tools` confirms available MCP/plugin tools. +- `GET /api/v1/knowledge/bases` confirms available knowledge bases. diff --git a/skills/skills/langbot-testing/references/mcp-stdio-testing.md b/skills/skills/langbot-testing/references/mcp-stdio-testing.md new file mode 100644 index 0000000..c1930b8 --- /dev/null +++ b/skills/skills/langbot-testing/references/mcp-stdio-testing.md @@ -0,0 +1,111 @@ +# MCP Stdio Testing + +Use this reference when validating MCP server creation, tool discovery, and local-agent tool calls. + +## Minimal Fixture + +Use the bundled test server: + +```text +fixtures/mcp/qa_mcp_echo_server.py +``` + +It exposes one tool: + +```text +qa_mcp_echo(text: str) -> str +``` + +Expected tool result: + +```text +qa_mcp_echo: +``` + +Older versions of this fixture used the visible name `qa_echo`, which collides +with the `qa-plugin-smoke` plugin tool. This fixture now uses the unique +`qa_mcp_echo` name. If a run still returns the plugin sentinel: + +```text +qa-plugin-smoke: +``` + +that proves the run used the plugin tool or a stale MCP registration, not the +current MCP fixture. Refresh the MCP server/tool registration before treating +the result as MCP coverage. + +## Browser Flow + +1. Open `LANGBOT_FRONTEND_URL`. +2. Navigate to `MCP Servers`. +3. Create a new MCP server. +4. Set mode to `Stdio`. +5. Fill the command and each argument separately: + - Command: `python` + - Arg 1: absolute path to `fixtures/mcp/qa_mcp_echo_server.py` +6. Click `Test`. +7. Submit the server. +8. Confirm the server page shows `Tools: 1` and `qa_mcp_echo`. + +Do not paste `python ...` into the command field as one string. LangBot stores `command` and `args` separately. + +## Tool Discovery Checks + +- UI: MCP detail page shows status connected and `qa_mcp_echo`. +- API diagnostic: `GET /api/v1/mcp/servers` shows `runtime_info.status=connected`. +- API diagnostic: `GET /api/v1/tools` contains `qa_mcp_echo`. + +## Provider-Independent Fixture Check + +Use this diagnostic before blaming Local Agent or a model provider: + +```bash +node scripts/e2e/mcp-stdio-fixture.mjs +``` + +It launches the bundled stdio server directly, lists tools over MCP, and calls +`qa_mcp_echo` without invoking a LangBot model. A pass proves the fixture and MCP +stdio framing work; it does not prove the provider-backed Local Agent tool loop. + +## LangBot Runtime Registration Check + +Use this diagnostic when the direct fixture passes but LangBot still lists an old +tool name or the saved MCP server may be stale: + +```bash +node scripts/e2e/mcp-stdio-register.mjs +``` + +It upserts `qa-local-stdio` through the authenticated WebUI session, points it at +the bundled `qa_mcp_echo_server.py`, then checks `/api/v1/tools` and the MCP +runtime info. A pass proves LangBot has refreshed the saved server and exposes +`qa_mcp_echo` before any model provider is involved. + +## Local-Agent Tool Call Check + +1. Open the target pipeline. +2. Confirm `Extensions` allows the MCP server, or that all MCP servers are enabled. +3. Use runner `Default` or the pluginized `langbot/local-agent` runner. +4. Select a model with function-calling ability that is known to work with tools in the current environment. +5. Open `Debug Chat`. +6. Ask: + +```text +Call the qa_mcp_echo tool with exactly this text: mcp-ok-local-agent. Return only the tool result. +``` + +Pass when the bot response contains: + +```text +qa_mcp_echo:mcp-ok-local-agent +``` + +Do not count this case as passed when the bot returns: + +```text +qa-plugin-smoke:mcp-ok-local-agent +``` + +That proves a plugin tool was called, not the MCP server. + +If the provider returns `model_not_found` or `no available channel` only when tools are supplied, switch to a known-good function-calling model before diagnosing MCP or local-agent. That failure means the selected model route is unavailable for the requested tool-call shape. diff --git a/skills/skills/langbot-testing/references/model-provider-testing.md b/skills/skills/langbot-testing/references/model-provider-testing.md new file mode 100644 index 0000000..1431d54 --- /dev/null +++ b/skills/skills/langbot-testing/references/model-provider-testing.md @@ -0,0 +1,25 @@ +# Model Provider Testing + +## Goal + +Verify that a model provider can be added, configured with a key, and tested from the WebUI. + +## Rules + +- Never print the API key or token value. +- Prefer using a test key supplied through the user's environment or secret manager. +- After saving a provider, use the WebUI test button when available. +- Confirm the provider is usable by running a small pipeline Debug Chat test, not only by checking that the form saved. + +## DeepSeek Flow + +1. Open `LANGBOT_FRONTEND_URL` from `skills/.env` or the active user-provided environment. +2. Go to `Models`. +3. Add or edit a DeepSeek provider. +4. Fill the required base URL, API key, and model fields according to the current UI. +5. Click the provider/model test button. +6. If the UI test succeeds, verify with a pipeline Debug Chat message. + +## Completion Signal + +Report the provider name, model name, UI test result, and pipeline Debug Chat result. Do not include secrets. diff --git a/skills/skills/langbot-testing/references/performance-reliability-testing.md b/skills/skills/langbot-testing/references/performance-reliability-testing.md new file mode 100644 index 0000000..42aaa04 --- /dev/null +++ b/skills/skills/langbot-testing/references/performance-reliability-testing.md @@ -0,0 +1,285 @@ +# Performance And Reliability Testing + +Use this reference when a QA request asks whether LangBot is fast enough, +stable under load, or resilient to controlled faults. + +These probes are manual/non-required QA gates unless a case or suite explicitly +states otherwise. They depend on a live local backend, mutable QA fixtures, and +operator-selected environment variables, so do not promote them to required CI +checks until fake-provider isolation, ownership markers, and cleanup are in +place. + +## Scope + +Treat `skills/` as the QA control plane: + +- Cases define intent, readiness, thresholds, and required evidence. +- Probe scripts collect metrics, traces, resource logs, and artifacts. +- Reports classify the same run as `pass`, `fail`, `blocked`, + `env_issue`, or `flaky`. + +Do not turn `skills/` into a load generator or chaos engine. Call a focused +tool from a `mode: probe` case when the test needs one, for example k6, +Locust, pytest-benchmark, Playwright trace collection, Toxiproxy, Docker, or a +Kubernetes disruption tool. + +## LangBot Performance Model + +For LangBot, performance is the cost LangBot adds around external systems: + +```text +LangBot overhead = end-to-end latency - provider latency - external tool latency - network/fault injection latency +``` + +Measure user experience and internal composition separately: + +- WebUI load and interaction latency. +- Debug Chat send-to-first-visible-token and send-to-completion latency. +- Pipeline, RAG, plugin runtime, MCP, AgentRunner, and persistence segment + latency. +- Queue wait time, concurrency, throughput, timeout rate, and p95/p99 latency. +- Startup, plugin install, knowledge-base ingestion, migration, and recovery + time. + +Do not report a single message round-trip time as "LangBot performance" unless +the report also explains external provider/tool/network time. + +## Evidence Contract + +Performance and reliability cases should declare the evidence they need: + +- `metrics`: machine-readable latency, throughput, error-rate, or recovery + metrics, usually `metrics.json`. +- `resource_log`: CPU, memory, process, connection, queue, or file descriptor + samples. +- `trace`: browser, HTTP, database, or runtime trace artifacts. +- `profile`: CPU, memory, or flamegraph profile artifacts. +- `backend_log`, `network`, `api_diagnostic`, and `filesystem` as supporting + evidence when relevant. + +Automation should write `automation-result.json` with these fields when +available: + +```json +{ + "status": "pass", + "reason": "Probe passed all thresholds.", + "metrics_summary": { + "langbot_overhead_p95_ms": 12.4, + "error_rate": 0 + }, + "thresholds_summary": { + "langbot_overhead_p95_ms": { "actual": 12.4, "max": 50, "pass": true } + }, + "artifacts": { + "metrics_json": "/path/to/metrics.json" + }, + "evidence_collected": ["metrics", "filesystem"] +} +``` + +Synthetic contract probes are useful for checking the QA harness, but they are +not live product performance results. Label them as contract probes in the case +title, checks, and report. + +## Chaos And Reliability Rules + +Chaos tests must be narrow and reversible: + +- Declare the fault model in `fault_model_json`. +- Record blast radius, target component, injection method, duration, and abort + conditions. +- Capture recovery checks and cleanup steps in the case. +- Classify unavailable dependencies as `env_issue` unless the target behavior + is LangBot's handling of that dependency failure. +- Do not run destructive fault injection against a shared or production-like + instance without explicit operator approval. + +Recommended first fault models: + +- Provider timeout or HTTP 429 from a fake provider endpoint. +- Plugin runtime disconnect/reconnect in a local instance. +- MCP stdio server exits mid-call. +- RAG parser fixture fails once and recovers on retry. +- Backend API endpoint returns 5xx from a controlled local proxy. + +## Starter Live Probes + +The starter gate separates QA-harness contracts from live product checks: + +- `langbot-overhead-accounting-contract` verifies that reports can carry + overhead accounting metrics. It uses deterministic synthetic samples and is + not live product performance. +- `langbot-fault-taxonomy-contract` verifies that fault scenarios declare + expected status, recovery, and cleanup before destructive chaos tests are + added. +- `langbot-live-backend-latency` checks the unauthenticated `/healthz` + endpoint for basic backend responsiveness. +- `langbot-live-control-plane-api` checks `/healthz` and + `/api/v1/system/info` for HTTP 200, JSON `code: 0`, response shape, and + per-endpoint p95 latency. +- `langbot-live-backend-log-health` scans the recent backend log window for + fail-severity runtime findings. It is the reliability guard that should fail + the gate when HTTP probes pass but backend logs contain Traceback, ImportError, + ERROR, unclosed sessions, or unawaited coroutine signals. + +Do not treat these starter live probes as Debug Chat or model-provider +performance. They are control-plane readiness checks; user-facing performance +needs browser/WebSocket/message-path measurements. + +## Debug Chat Load And Fake Provider Baseline + +Use `langbot-fake-provider-debug-chat-load` before real-provider load checks. +The setup automation starts a local OpenAI-compatible fake provider, registers +it as a normal LangBot provider/model, configures a local-agent pipeline, resets +Debug Chat, and then drives concurrent WebSocket messages through the live +backend. + +This is not a mocked backend test. It still exercises: + +- provider/model persistence and runtime reload; +- LiteLLM OpenAI-compatible requester path; +- local-agent runner selection and pipeline execution; +- Debug Chat WebSocket adapter and broadcast behavior; +- backend concurrency, timeout, and error-rate accounting. + +The fake provider is deterministic and can inject controlled latency or faults +with `LANGBOT_FAKE_PROVIDER_*` variables, so it is the baseline for LangBot +message-path overhead. A fake-provider process keeps process-global config, +request counters, and recent request history; run fake-provider probes serially +or give each run its own provider instance. Concurrent probes against the same +fake-provider URL can reset or reconfigure each other's metrics. + +The probe uses unique expected response tokens per +request because Debug Chat broadcasts messages to every connection in the same +session; unique tokens prevent one connection from counting another +connection's response as its own. + +When the fake provider is used, reports also include provider-side timing in +`metrics.json`: + +- `fake_provider.duration_ms` and `fake_provider.first_content_chunk_ms` + measure the controlled provider itself. +- `provider_timing.send_to_provider_start_ms` estimates WebSocket ingress, + pipeline dispatch, runner setup, and requester time before the provider + receives the request. +- `provider_timing.provider_finish_to_ws_final_ms` estimates the path from + provider completion back to the final Debug Chat WebSocket response. +- `provider_timing.langbot_overhead_estimate_ms` is the sum of those two + LangBot-side segments when wall-clock timestamps can be matched by the + unique expected response token. + +After the baseline passes, run `langbot-fake-provider-debug-chat-slow-load` to +keep the same live backend path while injecting deterministic streaming latency. +Run `langbot-fake-provider-debug-chat-fault-recovery` to inject bounded HTTP +provider failures and require both observed failures and later successful +requests. The fault-recovery case is deliberately sequential because failed +Debug Chat responses do not carry a unique success token that can be attributed +to one concurrent connection. + +Run `langbot-fake-provider-debug-chat-cross-pipeline-isolation` separately via +`langbot-debug-chat-isolation-gate`. Current LangBot releases may fail it because +of product bug [#2286](https://github.com/langbot-app/LangBot/issues/2286), where +Debug Chat replies can read singleton WebSocket proxy pipeline state after a +later message overwrites it. Treat that failure as regression evidence for the +product fix rather than as a fake-provider latency finding. + +Use `langbot-space-debug-chat-concurrency-smoke` after the fake-provider +baseline. It runs a deliberately small real Space-provider batch and reports +user-visible latency, not pure LangBot overhead. Space/model/network failures +are dependency findings until the fake baseline shows the same symptom. +If a Space smoke passes but log guard finds telemetry posting Tracebacks, +classify that separately as `telemetry-proxy-noise` instead of clearing the +proxy or treating the Debug Chat path as failed. + +Useful commands: + +```bash +rtk bin/lbs test run langbot-fake-provider-debug-chat-load --run-id langbot-fake-load-local +rtk bin/lbs test run langbot-fake-provider-debug-chat-slow-load --run-id langbot-fake-slow-local +rtk bin/lbs test run langbot-fake-provider-debug-chat-fault-recovery --run-id langbot-fake-fault-local +rtk bin/lbs suite run langbot-debug-chat-isolation-gate --run-id langbot-debug-chat-isolation-local --include-manual-check +rtk bin/lbs test run langbot-space-debug-chat-concurrency-smoke --run-id langbot-space-smoke-local +rtk bin/lbs suite run langbot-debug-chat-load-gate --run-id langbot-debug-chat-load-local --include-manual-check +``` + +## Gate Layers + +Use the smallest gate that answers the quality question: + +- `langbot-performance-contract-gate`: fast synthetic checks for report shape, + threshold accounting, and fault taxonomy. Good for PR feedback when no live + service is running. +- `langbot-live-backend-gate`: live backend `/healthz`, + `/api/v1/system/info`, and backend log health. Good after starting a local + LangBot backend. +- `langbot-user-path-performance-gate`: browser-visible user path performance, + starting with Pipeline Debug Chat send-to-visible-completion latency. Run it + only when the browser profile and target pipeline are ready. +- `langbot-debug-chat-load-gate`: manual WebSocket Debug Chat load checks, + starting with controlled fake-provider baseline, slow-provider, and + fault-recovery profiles, plus an optional low-volume real Space-provider + smoke. Run fake-provider cases serially when they share a provider URL. +- `langbot-debug-chat-isolation-gate`: manual cross-pipeline Debug Chat + isolation regression gate. Current releases may fail because of #2286; keep it + separate from the normal load gate until that product fix lands. +- `langbot-performance-reliability-gate`: combined starter gate for synthetic + contracts plus live backend checks. + +Keep environment diagnostics separate from product regressions. For example, a +SOCKS proxy without Python `socksio` support should be fixed or clearly +classified by `bin/lbs env doctor`; do not hide the resulting backend +Traceback in reports. + +## Debug Chat Performance + +`pipeline-debug-chat-performance` reuses the browser Debug Chat automation and +adds `metrics.json`, `metrics_summary`, and `thresholds_summary` to +`automation-result.json`. + +Current metric: + +```text +response_duration_ms = prompt send -> expected assistant response visible and stable +``` + +This is a user-path metric, not pure LangBot overhead. If it regresses, inspect +provider latency, model route health, plugin/runtime logs, WebSocket behavior, +and browser console/network evidence before attributing the whole duration to +LangBot. + +### User-Path Gate Runbook + +1. Start the backend and frontend. The frontend must be launched with + `VITE_API_BASE_URL="$LANGBOT_BACKEND_URL"` so browser API calls reach the + backend. +2. Run `node scripts/e2e/ensure-local-agent-pipeline.mjs --write-env`. The + setup refreshes the local QA login, skips the wizard, prepares a Debug Chat + pipeline, scans Space models, tests candidates, writes tested fallback + models, and writes the selected pipeline/model env values to + `skills/.env.local`. +3. If setup returns `env_issue`, read `model_tests` and provider errors first. + A missing Space key, failed Space scan, or unavailable model route is not a + LangBot performance regression. +4. Run + `bin/lbs suite run langbot-user-path-performance-gate --include-manual-check`. +5. Interpret `response_p95_ms` as browser-visible send-to-completion time. It + includes provider latency; use backend logs and model test evidence to + separate LangBot overhead from the external model route. + +The setup keeps a `max-round` value in the generated pipeline config only +because the current backend truncator still reads that field directly. Do not +use it as a quality requirement for future local-agent behavior. + +## Running The First Gate + +Start with the reusable suite: + +```bash +rtk bin/lbs suite plan langbot-performance-reliability-gate +rtk bin/lbs suite start langbot-performance-reliability-gate --run-id langbot-perf-rel-local +``` + +Run synthetic contract probes first. Run live probes only after the selected +backend/frontend instance is reachable and the run owner accepts any fault +scope. diff --git a/skills/skills/langbot-testing/references/pipeline-debug-chat.md b/skills/skills/langbot-testing/references/pipeline-debug-chat.md new file mode 100644 index 0000000..7059c1b --- /dev/null +++ b/skills/skills/langbot-testing/references/pipeline-debug-chat.md @@ -0,0 +1,53 @@ +# Pipeline Debug Chat + +## Goal + +Verify that a pipeline can receive a private debug message and return a bot response through the configured frontend. + +## Path + +1. Open `LANGBOT_FRONTEND_URL` from `skills/.env` or the active user-provided environment. +2. Navigate to `Pipelines`. +3. Open the target pipeline. +4. Select `Debug Chat`. +5. Send a short deterministic prompt, for example: + + ```text + 请只回复 OK,用于前端调试测试。 + ``` + +## Success Criteria + +The UI should show: + +- A `User` message containing the prompt. +- A `Bot` message containing the expected response, for example `OK`. + +When the prompt itself contains a sentinel token, do not treat `document.body` containing that token as success. Confirm the token appears in a `Bot`/assistant message, WebSocket history entry, or backend completion log. + +For `scripts/e2e/pipeline-debug-chat.mjs`, inspect +`automation-result.json` when a sentinel is present in the prompt. A pass should +show the expected text in a new assistant message; the +`after_assistant_expected_count` value must increase beyond +`before_assistant_expected_count`. If only the user prompt contains the +sentinel, the run is a failure even when the page body contains enough total +occurrences. + +The backend log should include: + +```text +Processing request from person_websocket... +Streaming completed +``` + +## Failure Criteria + +Treat the test as failed if: + +- Only the user message appears. +- The page shows `Agent runner temporarily unavailable`. +- Backend logs contain `All models failed during streaming setup`. +- Backend logs contain `Action invoke_llm_stream call timed out`. +- Backend logs contain `Action list_plugins call timed out`. + +When failures match these signatures, read `troubleshooting.md`. diff --git a/skills/skills/langbot-testing/references/plugin-e2e-smoke.md b/skills/skills/langbot-testing/references/plugin-e2e-smoke.md new file mode 100644 index 0000000..c77cf36 --- /dev/null +++ b/skills/skills/langbot-testing/references/plugin-e2e-smoke.md @@ -0,0 +1,66 @@ +# Plugin E2E Smoke + +Use this reference to validate LangBot plugin behavior with a browser-first flow and API/log diagnostics. + +## Fixture + +Use the bundled local plugin source: + +```text +fixtures/plugins/qa-plugin-smoke +``` + +It registers: + +- Plugin id: `qa/plugin-smoke` +- Tool: `qa_echo(text: string)` returning `qa-plugin-smoke:` +- Tool: `qa_plugin_echo(text: string)` returning `qa-plugin-smoke:` +- Tool: `qa_plugin_sleep(seconds: number, text: string)` returning `qa-plugin-smoke:sleep::` after a bounded delay +- Page: `smoke`, with an HTML asset and a backend page API sentinel `qa-plugin-smoke-page` + +## SDK Under Test + +When validating a local SDK build, install it into the LangBot worktree virtualenv: + +```bash +cd "$LANGBOT_REPO" +uv pip install -e /absolute/path/to/langbot-plugin-sdk-test-build +uv run --no-sync python -c "import langbot_plugin, pathlib; print(pathlib.Path(langbot_plugin.__file__).resolve())" +``` + +The printed path must point into the local SDK source tree. Use `uv run --no-sync` for LangBot startup and tests; plain `uv run` may sync the lockfile and restore the PyPI package. + +## Build The Fixture + +From the fixture directory, build with the same SDK that LangBot will run: + +```bash +cd skills/langbot-testing/fixtures/plugins/qa-plugin-smoke +"$LANGBOT_REPO/.venv/bin/lbp" build +``` + +The generated zip under `dist/` is the file to upload from the WebUI. + +## Browser Flow + +1. Start or verify backend and frontend. +2. Open `LANGBOT_FRONTEND_URL`. +3. Initialize or log in to the test instance. +4. Navigate to `Plugins`. +5. Choose local plugin install and upload the generated `qa-plugin-smoke` zip. +6. Wait for the install task to finish. +7. Confirm the plugin list/detail shows `QA Plugin Smoke`, `qa_echo`, and `Smoke Page`. +8. Open the plugin extension page if it appears in the sidebar and verify it renders the sentinel text. + +## Diagnostic Checks + +Use API checks only to confirm what the UI exercised: + +- `GET /api/v1/plugins` contains `qa/plugin-smoke` with initialized status. +- `GET /api/v1/tools` contains `qa_echo`, `qa_plugin_echo`, and `qa_plugin_sleep`. +- `POST /api/v1/plugins/qa/plugin-smoke/page-api` with `page_id=smoke`, `endpoint=/ping`, `method=GET` returns `qa-plugin-smoke-page`. +- Backend logs include `Connected to plugin runtime` and no `Action ... call timed out` entries. + +## Cleanup + +Delete `qa/plugin-smoke` through the WebUI or `DELETE /api/v1/plugins/qa/plugin-smoke?delete_data=true` after recording results. diff --git a/skills/skills/langbot-testing/references/sandbox-skill-authoring.md b/skills/skills/langbot-testing/references/sandbox-skill-authoring.md new file mode 100644 index 0000000..db9b826 --- /dev/null +++ b/skills/skills/langbot-testing/references/sandbox-skill-authoring.md @@ -0,0 +1,136 @@ +# Sandbox Skill Authoring + +## Goal + +Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat. + +This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence. + +## Preconditions + +1. Read `../.env` and use `LANGBOT_FRONTEND_URL` and `LANGBOT_BACKEND_URL`. +2. Start LangBot with the intended backend: + - `BOX_BACKEND=e2b` when validating E2B. + - `BOX_BACKEND=nsjail` when validating nsjail. + - `BOX_BACKEND=local` or `docker` when validating local container fallback. +3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name. +4. Confirm Debug Chat uses a model with function-calling ability. +5. Confirm backend logs say native sandbox tools are available. + +Do not store sandbox provider keys, JWTs, OAuth tokens, or localStorage values in the case or notes. + +## Debug Chat Prompt Pattern + +Use a unique skill name per run, for example: + +```text +lb-sandbox-agent-e2e- +``` + +Send a prompt that requires the model to do all of the following: + +1. Use `exec` to create a multi-file skill under `/workspace/`. +2. Include at least: + - `SKILL.md` + - `scripts/use.py` + - `data/input.json` +3. In the same first `exec`, run the script and verify a deterministic marker such as: + + ```text + SANDBOX_COMPLEX_SKILL_OK sum=10 product=24 + ``` + +4. Call `register_skill` with `path=/workspace/`. +5. Call `activate` with `skill_name=`. +6. Call `exec` with `workdir=/workspace/.skills/` and run: + + ```bash + python3 scripts/use.py && echo SANDBOX_ACTIVATED_WRITEBACK_OK > activated_writeback.txt && cat activated_writeback.txt + ``` + +7. Require the final answer to contain only an explicit success marker: + + ```text + E2E_OK: + ``` + +Keep the test script robust to working-directory changes. Prefer resolving data paths from `__file__`: + +```python +from pathlib import Path +data_path = Path(__file__).resolve().parent.parent / "data" / "input.json" +``` + +## Success Criteria + +The UI should show an assistant final response containing `E2E_OK:`. + +Backend logs should show: + +- `exec tool invoked` +- `register_skill` +- `activate` +- a second `exec` whose workdir is `/workspace/.skills/` +- `backend=e2b`, `backend=nsjail`, or the expected local backend + +After the run, verify the skill store through the UI or API: + +- Skill root lists `SKILL.md`, `scripts`, `data`, and `activated_writeback.txt`. +- `scripts/use.py` is readable. +- `data/input.json` is readable. +- `activated_writeback.txt` contains `SANDBOX_ACTIVATED_WRITEBACK_OK`. +- `/api/v1/box/errors` is empty. + +## Existing Skill Edit Variant + +The base `sandbox-skill-authoring-e2e` case only proves create, register, +activate, and use. To prove that an already activated skill can be modified, +run `sandbox-skill-authoring-edit-existing-e2e` or use its prompt pattern. + +The edit variant must include these additional checks: + +- The second `exec` uses `workdir=/workspace/.skills/`. +- The second `exec` overwrites `SKILL.md`, `data/input.json`, and + `scripts/use.py` under the activated skill path. +- The modified script prints a deterministic marker such as: + + ```text + SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker= + ``` + +- `grep -q SKILL.md scripts/use.py data/input.json` succeeds + in the same activated-path command. +- Filesystem evidence under the Box-managed skill store shows the updated + marker, not only the original create marker. + +If the model stops after activation and only reruns the original script, treat +that run as a failed edit-existing E2E even when create/register/activate +succeeded. + +## Diagnostic Checks + +Use these only after the model-driven Debug Chat flow fails: + +- `/api/v1/box/status` to confirm backend selection and recent errors. +- `/api/v1/box/sessions` to check leaked or conflicting sessions. +- Direct backend probes to separate provider credentials from LangBot integration. +- Filesystem inspection under the configured Box-managed skill store. + +For E2B raw HTTP diagnostics, include a valid template id such as `base`; a missing template can produce schema validation errors that are unrelated to authentication. + +## Known Pitfalls + +- When Box is available, skills may be owned by the Box runtime and stored in Box-managed skill storage. Do not assume `data/skills` is the active source of truth. +- Public E2B does not provide local bind mounts. Main workspace and activated skill extra mounts must be synchronized into and back out of the E2B sandbox. +- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible. +- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`. +- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox. +- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed. + +## Related Troubleshooting + +- `sandbox-native-tools-unavailable` +- `e2b-extra-mount-sync-missing` +- `box-session-conflict-logical-metadata` +- `nsjail-cli-compatibility` +- `socks-proxy-without-socksio` diff --git a/skills/skills/langbot-testing/references/troubleshooting.md b/skills/skills/langbot-testing/references/troubleshooting.md new file mode 100644 index 0000000..286990c --- /dev/null +++ b/skills/skills/langbot-testing/references/troubleshooting.md @@ -0,0 +1,91 @@ +# Troubleshooting + +Troubleshooting entries are now managed as structured YAML under `../troubleshooting/`. Use `bin/lbs trouble add langbot-testing ...` to add new entries when a new failure mode is confirmed. + +This Markdown file is a human-readable legacy summary. Prefer the YAML entries for automation. + +## plugin-runtime-timeout: Plugin runtime actions time out + +Structured entry: `../troubleshooting/plugin-runtime-timeout.yaml` + +Date: 2026-05-16 + +### Symptom + +The WebUI can send a Debug Chat message, but the bot response is missing or says `Agent runner temporarily unavailable`. Backend logs may include `Action list_plugins call timed out`, `Action list_agent_runners call timed out`, or `Action invoke_llm_stream call timed out`. + +### Likely Cause + +An old `langbot_plugin` runtime process survived a backend restart, or multiple runtime processes are active at once. The backend is running, but plugin actions do not get a valid response. + +### Fix + +Stop the LangBot backend and any orphaned `langbot_plugin.cli` runtime processes, confirm the configured backend URL is free/reachable as appropriate, then start LangBot again. A healthy startup logs `Connected to plugin runtime`, mounts `langbot/local-agent`, and initializes the default agent runner. + +### Verification + +Run a pipeline Debug Chat prompt. The UI should show a Bot response, and backend logs should include `Streaming completed`. + +## proxy-env-mismatch: Uppercase and lowercase proxy variables differ + +Structured entry: `../troubleshooting/proxy-env-mismatch.yaml` + +Date: 2026-05-16 + +### Symptom + +External model calls time out even though the proxy appears to be configured. Environment inspection shows uppercase proxy variables using `127.0.0.1:7890` while lowercase variables still point to an old WSL gateway such as `172.30.144.1:7890`. + +### Likely Cause + +Different libraries read different proxy variable names. If lowercase and uppercase values differ, model/provider calls can use the wrong proxy. + +### Fix + +Start LangBot with consistent `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `http_proxy`, `https_proxy`, `all_proxy`, `NO_PROXY`, and `no_proxy` values. + +### Verification + +Backend startup should no longer show old proxy addresses, and a pipeline Debug Chat should complete with a Bot response. + +## mcp-stdio-args-not-applied: MCP Stdio test runs only the command without arguments + +Structured entry: `../troubleshooting/mcp-stdio-args-not-applied.yaml` + +The MCP form may appear filled correctly, but the backend logs show bare `uv` help text or `Connection closed`. Confirm command and args are split correctly, then check whether the form test handler reads the latest stdio args. + +## survey-widget-blocks-debug-chat: Survey widget blocks Debug Chat controls + +Structured entry: `../troubleshooting/survey-widget-blocks-debug-chat.yaml` + +If Playwright cannot click Debug Chat `Send` because a fixed bottom-right element intercepts pointer events, close or minimize the survey widget before continuing the browser test. + +## dynamic-form-missing-config-id: Dynamic form fields have no stable key + +Structured entry: `../troubleshooting/dynamic-form-missing-config-id.yaml` + +Dynamic plugin/runner schemas can trigger React unique-key warnings when schema items lack `id`. Prefer backend-generated stable ids and keep frontend fallback keys. + +## pipeline-form-controlled-warning: Pipeline form input switches from uncontrolled to controlled + +Structured entry: `../troubleshooting/pipeline-form-controlled-warning.yaml` + +Pipeline edit forms should initialize string fields to empty strings and coalesce loaded nullable values before rendering inputs. + +## marketplace-network-flaky: Marketplace requests are flaky but plugin data may still load + +Structured entry: `../troubleshooting/marketplace-network-flaky.yaml` + +Marketplace icon/tag/recommendation requests can fail while plugin cards are already visible. Retry first, and use backend component endpoints only to confirm installation results. + +## agent-runner-actor-context-fields: AgentRunner reads old actor fields + +Structured entry: `../troubleshooting/agent-runner-actor-context-fields.yaml` + +If Debug Chat fails and logs include `ActorContext` missing `type` or `id`, update runner code to use protocol v1 fields `actor_type` and `actor_id`. + +## ambiguous-runner-default-label: Runner selector shows multiple Default options + +Structured entry: `../troubleshooting/ambiguous-runner-default-label.yaml` + +Keep `metadata.name: default` for stable component ids, but set user-facing `metadata.label` to provider-specific names such as `Dify` or `本地 Agent`. diff --git a/skills/skills/langbot-testing/references/web-ui-testing.md b/skills/skills/langbot-testing/references/web-ui-testing.md new file mode 100644 index 0000000..8a2421f --- /dev/null +++ b/skills/skills/langbot-testing/references/web-ui-testing.md @@ -0,0 +1,37 @@ +# Web UI Testing + +## Baseline + +- Read shared defaults from `skills/.env`. +- Open `LANGBOT_FRONTEND_URL`. +- Use `LANGBOT_BACKEND_URL` for backend/API/log checks. +- Use Playwright MCP or another browser automation tool with a persisted authenticated profile. + +## Workflow + +1. Start or verify the backend. +2. Start or verify the selected frontend. +3. Open `LANGBOT_FRONTEND_URL`. +4. Confirm the sidebar shows the logged-in user instead of the login page. +5. Navigate through the target flow with role/text selectors where possible. +6. Check browser console errors, visible UI state, and backend logs. + +## Browser Vs API Boundary + +Use browser automation as the acceptance path for WebUI cases. API or curl checks are useful for readiness, saved config inspection, and log correlation, but they do not cover login state, form rendering, frontend save behavior, websocket streaming, or console regressions. + +For a UI case, curl can support the report but cannot make the case pass by itself. A passing report should include the visible browser result and any backend/API diagnostics that explain the same run. + +## Authentication Notes + +If the user logged in on one origin but `LANGBOT_FRONTEND_URL` still shows `/login`, copy only the auth state needed for the selected origin. Do not print token values. + +## Completion Signal + +Report: + +- URL tested. +- User action performed. +- Visible result. +- Backend or network confirmation. +- Any console/backend errors that remain. diff --git a/skills/skills/langbot-testing/suites/agent-runner-release-gate.yaml b/skills/skills/langbot-testing/suites/agent-runner-release-gate.yaml new file mode 100644 index 0000000..a2c1506 --- /dev/null +++ b/skills/skills/langbot-testing/suites/agent-runner-release-gate.yaml @@ -0,0 +1,38 @@ +id: agent-runner-release-gate +title: "Agent runner externalization release gate" +description: "Release gate for runner externalization: pytest probes, environment preflight, fixture registration, local-agent capability paths, and ACP external harness execution." +type: release_gate +priority: p0 +tags: + - agent-runner + - release-gate + - local-agent + - external-runner +cases: + - agent-runner-fixture-contract + - agent-runner-behavior-matrix + - agent-runner-ledger-invariants + - agent-runner-async-db-readiness + - agent-runner-ledger-stress + - agent-runner-ledger-contention + - agent-runner-ledger-concurrency + - agent-runner-runtime-chaos + - agent-runner-live-install + - agent-runner-qa-debug-chat + - agent-runner-release-preflight + - webui-login-state + - pipeline-debug-chat + - qa-plugin-smoke-live-install + - plugin-e2e-smoke + - langrag-kb-retrieve + - local-agent-basic-debug-chat + - local-agent-effective-prompt-debug-chat + - local-agent-context-compaction-debug-chat + - local-agent-rag-debug-chat + - local-agent-plugin-tool-call-debug-chat + - mcp-stdio-register + - mcp-stdio-tool-call + - local-agent-nonstreaming-debug-chat + - local-agent-multimodal-debug-chat + - local-agent-rag-multimodal-debug-chat + - acp-agent-runner-debug-chat diff --git a/skills/skills/langbot-testing/suites/core-smoke.yaml b/skills/skills/langbot-testing/suites/core-smoke.yaml new file mode 100644 index 0000000..d0cfe91 --- /dev/null +++ b/skills/skills/langbot-testing/suites/core-smoke.yaml @@ -0,0 +1,13 @@ +id: core-smoke +title: "Core browser smoke suite" +description: "Fast browser-first checks for login state, Pipeline Debug Chat, and the basic local-agent runner path." +type: smoke +priority: p0 +tags: + - smoke + - browser + - p0 +cases: + - webui-login-state + - pipeline-debug-chat + - local-agent-basic-debug-chat diff --git a/skills/skills/langbot-testing/suites/langbot-debug-chat-isolation-gate.yaml b/skills/skills/langbot-testing/suites/langbot-debug-chat-isolation-gate.yaml new file mode 100644 index 0000000..d2b31dd --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-debug-chat-isolation-gate.yaml @@ -0,0 +1,13 @@ +id: langbot-debug-chat-isolation-gate +title: "LangBot Debug Chat isolation gate" +description: "Manual/non-required cross-pipeline Debug Chat isolation gate. Current releases may fail this gate because of product bug #2286; use it as regression evidence after the routing fix lands." +type: reliability +priority: p1 +tags: + - reliability + - debug-chat + - websocket + - isolation + - concurrency +cases: + - langbot-fake-provider-debug-chat-cross-pipeline-isolation diff --git a/skills/skills/langbot-testing/suites/langbot-debug-chat-load-gate.yaml b/skills/skills/langbot-testing/suites/langbot-debug-chat-load-gate.yaml new file mode 100644 index 0000000..5b4950f --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-debug-chat-load-gate.yaml @@ -0,0 +1,15 @@ +id: langbot-debug-chat-load-gate +title: "LangBot Debug Chat load gate" +description: "Manual/non-required message-path load checks for Pipeline Debug Chat: controlled fake-provider baseline, slow-provider and fault-recovery profiles, plus optional real Space-provider smoke. Cross-pipeline isolation is split into langbot-debug-chat-isolation-gate because current releases may fail it due to product bug #2286." +type: performance +priority: p1 +tags: + - performance + - debug-chat + - websocket + - load +cases: + - langbot-fake-provider-debug-chat-load + - langbot-fake-provider-debug-chat-slow-load + - langbot-fake-provider-debug-chat-fault-recovery + - langbot-space-debug-chat-concurrency-smoke diff --git a/skills/skills/langbot-testing/suites/langbot-live-backend-gate.yaml b/skills/skills/langbot-testing/suites/langbot-live-backend-gate.yaml new file mode 100644 index 0000000..58a9785 --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-live-backend-gate.yaml @@ -0,0 +1,14 @@ +id: langbot-live-backend-gate +title: "LangBot live backend reliability gate" +description: "Live backend control-plane responsiveness and runtime log health checks for a locally running LangBot instance." +type: reliability +priority: p1 +tags: + - performance + - reliability + - live-backend + - metrics +cases: + - langbot-live-backend-latency + - langbot-live-control-plane-api + - langbot-live-backend-log-health diff --git a/skills/skills/langbot-testing/suites/langbot-performance-contract-gate.yaml b/skills/skills/langbot-testing/suites/langbot-performance-contract-gate.yaml new file mode 100644 index 0000000..b5a9eb4 --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-performance-contract-gate.yaml @@ -0,0 +1,13 @@ +id: langbot-performance-contract-gate +title: "LangBot performance contract gate" +description: "Fast synthetic contract checks for performance metric accounting and non-destructive reliability fault taxonomy." +type: contract +priority: p1 +tags: + - performance + - reliability + - contract + - metrics +cases: + - langbot-overhead-accounting-contract + - langbot-fault-taxonomy-contract diff --git a/skills/skills/langbot-testing/suites/langbot-performance-reliability-gate.yaml b/skills/skills/langbot-testing/suites/langbot-performance-reliability-gate.yaml new file mode 100644 index 0000000..1e0d58d --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-performance-reliability-gate.yaml @@ -0,0 +1,16 @@ +id: langbot-performance-reliability-gate +title: "LangBot performance and reliability starter gate" +description: "Starter gate for LangBot performance accounting, live backend control-plane latency, and non-destructive fault taxonomy checks." +type: reliability +priority: p1 +tags: + - performance + - reliability + - metrics + - chaos +cases: + - langbot-overhead-accounting-contract + - langbot-fault-taxonomy-contract + - langbot-live-backend-latency + - langbot-live-control-plane-api + - langbot-live-backend-log-health diff --git a/skills/skills/langbot-testing/suites/langbot-user-path-performance-gate.yaml b/skills/skills/langbot-testing/suites/langbot-user-path-performance-gate.yaml new file mode 100644 index 0000000..a6a138e --- /dev/null +++ b/skills/skills/langbot-testing/suites/langbot-user-path-performance-gate.yaml @@ -0,0 +1,12 @@ +id: langbot-user-path-performance-gate +title: "LangBot user-path performance gate" +description: "Browser-visible performance checks for user-facing LangBot paths such as Pipeline Debug Chat." +type: performance +priority: p1 +tags: + - performance + - browser + - debug-chat + - user-path +cases: + - pipeline-debug-chat-performance diff --git a/skills/skills/langbot-testing/suites/local-agent-gate.yaml b/skills/skills/langbot-testing/suites/local-agent-gate.yaml new file mode 100644 index 0000000..42eee02 --- /dev/null +++ b/skills/skills/langbot-testing/suites/local-agent-gate.yaml @@ -0,0 +1,21 @@ +id: local-agent-gate +title: "Local Agent runner regression gate" +description: "High-signal local-agent runner checks covering prompt bridge, RAG, context compaction, plugin tools, MCP tools, non-streaming, and multimodal paths." +type: regression +priority: p1 +tags: + - local-agent + - agent-runner + - regression +cases: + - local-agent-basic-debug-chat + - qa-plugin-smoke-live-install + - local-agent-effective-prompt-debug-chat + - local-agent-context-compaction-debug-chat + - local-agent-rag-debug-chat + - local-agent-plugin-tool-call-debug-chat + - local-agent-steering-debug-chat + - mcp-stdio-tool-call + - local-agent-nonstreaming-debug-chat + - local-agent-multimodal-debug-chat + - local-agent-rag-multimodal-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/agent-runner-actor-context-fields.yaml b/skills/skills/langbot-testing/troubleshooting/agent-runner-actor-context-fields.yaml new file mode 100644 index 0000000..48c9801 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/agent-runner-actor-context-fields.yaml @@ -0,0 +1,22 @@ +id: agent-runner-actor-context-fields +title: "AgentRunner reads old actor.type and actor.id fields" +date: 2026-05-17 +symptoms: + - "Pipeline Debug Chat shows Agent runner execution failed." + - "Backend logs show an AttributeError from an AgentRunner plugin." +patterns: + - "AttributeError: 'ActorContext' object has no attribute 'type'" + - "AttributeError: 'ActorContext' object has no attribute 'id'" + - "return f\"{actor.type}_{actor.id}\"" +likely_causes: + - "The plugin was written against old actor field names." + - "Protocol v1 ActorContext uses actor_type and actor_id." +fix_steps: + - "Update runner code to read actor.actor_type and actor.actor_id." + - "Keep getattr fallback to type/id only if compatibility with older host data is required." + - "Restart LangBot or the plugin runtime so the updated plugin code is loaded." + - "Add a regression test that builds AgentRunContext with ActorContext(actor_type=..., actor_id=...)." +verification: "Run dify-agent-debug-chat or another AgentRunner Debug Chat and confirm the assistant/bot message contains the expected sentinel while backend logs show Streaming completed." +related_cases: + - dify-agent-debug-chat + - pipeline-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/aiosqlite-connect-hangs.yaml b/skills/skills/langbot-testing/troubleshooting/aiosqlite-connect-hangs.yaml new file mode 100644 index 0000000..796a6db --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/aiosqlite-connect-hangs.yaml @@ -0,0 +1,23 @@ +id: aiosqlite-connect-hangs +title: "aiosqlite connect hangs before ledger pytest starts" +category: env_issue +symptoms: + - "AgentRunner ledger pytest probe times out after collecting tests but before reporting a test result." + - "pytest stdout stops at a line like tests/unit_tests/agent/test_run_ledger_store.py." + - "A direct aiosqlite.connect(':memory:') script prints its first line and then hangs." +patterns: + - "pytest timed out after" + - "tests/unit_tests/agent/test_run_ledger_store.py" + - "aiosqlite.connect" +likely_causes: + - "The current execution environment cannot complete aiosqlite connection startup." + - "The failure is below LangBot ledger logic when direct aiosqlite connection also hangs." + - "Threading and synchronous sqlite can still work, so this is narrower than a general Python or SQLite outage." +fix_steps: + - "Run `rtk bin/lbs test run agent-runner-ledger-invariants` first for fast ledger schema/status coverage that does not use aiosqlite." + - "Classify `agent-runner-ledger-concurrency` timeout as env_issue when direct aiosqlite.connect also hangs." + - "Retest the async pytest target in an environment where aiosqlite connect completes." +verification: "A direct aiosqlite.connect(':memory:') script completes, then `rtk bin/lbs test run agent-runner-ledger-concurrency` no longer times out." +related_cases: + - agent-runner-ledger-concurrency + - agent-runner-ledger-invariants diff --git a/skills/skills/langbot-testing/troubleshooting/ambiguous-runner-default-label.yaml b/skills/skills/langbot-testing/troubleshooting/ambiguous-runner-default-label.yaml new file mode 100644 index 0000000..74e03df --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/ambiguous-runner-default-label.yaml @@ -0,0 +1,22 @@ +id: ambiguous-runner-default-label +title: "AgentRunner selector shows multiple Default or 默认 options" +date: 2026-05-17 +symptoms: + - "The Pipeline AI runner selector shows multiple options named Default or 默认." + - "Users must inspect plugin ids to distinguish Dify, Local Agent, or other runners." +patterns: + - "metadata.name: default" + - "label.zh_Hans: 默认" + - "label.en_US: Default" +likely_causes: + - "AgentRunner component ids are commonly named default, but the user-facing metadata.label was also left generic." + - "The frontend displays metadata.label as the primary option label." +fix_steps: + - "Keep metadata.name as default if the plugin component id is intended to remain stable." + - "Change metadata.label to the provider or runner family name, such as Dify, Local Agent, Coze, DashScope, Langflow, n8n, or Tbox." + - "Restart LangBot or plugin runtime to refresh runner metadata cache." + - "Check /api/v1/pipelines/_/metadata and the WebUI runner selector." +verification: "Runner options are distinguishable by visible label while their ids remain stable, such as plugin:langbot/dify-agent/default." +related_cases: + - dify-agent-debug-chat + - local-agent-rag-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/backend-not-listening.yaml b/skills/skills/langbot-testing/troubleshooting/backend-not-listening.yaml new file mode 100644 index 0000000..37df32d --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/backend-not-listening.yaml @@ -0,0 +1,29 @@ +id: backend-not-listening +title: "LangBot backend URL has no listening service" +date: 2026-05-30 +symptoms: + - "The frontend URL opens, but backend-dependent WebUI cases are blocked." + - "env doctor fails only for LANGBOT_BACKEND_URL." + - "curl to the backend port fails before receiving an HTTP status." +patterns: + - "LANGBOT_BACKEND_URL" + - "no HTTP service reachable" + - "Failed to connect to 127.0.0.1 port 5300" + - "TypeError: fetch failed" + - "Couldn't connect to server" +likely_causes: + - "The LangBot backend process is not running." + - "The frontend dev server is running by itself, so the browser opens but API calls cannot work." + - "The backend was started in a disposable foreground session and stopped when that session ended." + - "A detached startup command exited early and no process remains bound to the configured port." +fix_steps: + - "Run bin/lbs env doctor and confirm whether LANGBOT_BACKEND_URL reports no listener." + - "Start the backend from LANGBOT_REPO with uv run main.py." + - "Wait for Running on http://0.0.0.0:5300 and Connected to plugin runtime." + - "Verify ss -ltnp shows the configured backend port." + - "Re-run bin/lbs env doctor before starting browser QA." +verification: "LANGBOT_BACKEND_URL returns an HTTP status, env doctor passes the backend check, and backend logs show Connected to plugin runtime." +related_cases: + - pipeline-debug-chat + - webui-login-state + - local-agent-basic-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/box-session-conflict-logical-metadata.yaml b/skills/skills/langbot-testing/troubleshooting/box-session-conflict-logical-metadata.yaml new file mode 100644 index 0000000..7443fda --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/box-session-conflict-logical-metadata.yaml @@ -0,0 +1,23 @@ +id: box-session-conflict-logical-metadata +title: "BoxSessionConflictError after a successful first exec" +date: 2026-05-18 +symptoms: + - "The first sandbox exec succeeds, but the next exec in the same Debug Chat fails." + - "The model repeatedly retries commands and eventually reports a session conflict." + - "Skill registration or activation cannot continue after the first command." +patterns: + - "BoxSessionConflictError" + - "already exists with image=host" + - "already exists with mount_path=/home/user/workspace" +likely_causes: + - "Backend session metadata stored provider-specific values instead of LangBot logical spec values." + - "E2B stored adapted /home/user/workspace as mount_path, while later specs still use /workspace." + - "nsjail stored image=host, while later specs still use the configured logical sandbox image." +fix_steps: + - "Store logical spec.mount_path in BoxSessionInfo for E2B session metadata." + - "Store spec.image in nsjail BoxSessionInfo even though nsjail executes against host-mounted system paths." + - "Keep provider-specific path rewriting inside the backend command execution layer." + - "Restart LangBot to clear stale in-memory sessions after applying the fix." +verification: "Run two or more exec calls in the same Debug Chat session. The second call should reuse the session instead of raising BoxSessionConflictError." +related_cases: + - sandbox-skill-authoring-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/debug-chat-history-contaminates-automation.yaml b/skills/skills/langbot-testing/troubleshooting/debug-chat-history-contaminates-automation.yaml new file mode 100644 index 0000000..3b0156c --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/debug-chat-history-contaminates-automation.yaml @@ -0,0 +1,25 @@ +id: debug-chat-history-contaminates-automation +title: "Old Debug Chat messages contaminate automation assertions" +date: 2026-05-21 +symptoms: + - "A browser automation run reports Agent runner temporarily unavailable even though the latest bot response contains the expected text." + - "The screenshot shows an old failure bubble above the latest successful exchange." + - "The automation scans the whole document body instead of comparing only new messages or new failure counts." +patterns: + - "Agent runner temporarily unavailable" + - "Expected text appeared enough times" + - "Debug Chat displayed a known failure signal" +likely_causes: + - "Debug Chat history persists across runs and old failed messages remain visible." + - "The automation checks failure text anywhere in document.body after the run." + - "The automation does not compare failure counts before and after sending the prompt." +fix_steps: + - "Before sending the prompt, record counts for expected text and known failure text." + - "After sending the prompt, require the expected text count to increase enough for the user prompt plus bot response." + - "Treat known failure text as a new failure only if its count increased during this run." + - "For manual review, inspect the newest user/bot message pair rather than the whole chat transcript." +verification: "A latest successful bot response is not failed by old failure bubbles already present in the Debug Chat history." +related_cases: + - pipeline-debug-chat + - local-agent-plugin-tool-call-debug-chat + - mcp-stdio-tool-call diff --git a/skills/skills/langbot-testing/troubleshooting/dynamic-form-missing-config-id.yaml b/skills/skills/langbot-testing/troubleshooting/dynamic-form-missing-config-id.yaml new file mode 100644 index 0000000..6504b9a --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/dynamic-form-missing-config-id.yaml @@ -0,0 +1,21 @@ +id: dynamic-form-missing-config-id +title: "Dynamic form fields have no stable key" +date: 2026-05-17 +symptoms: + - "Browser console shows a React warning about children in a list needing a unique key." + - "The warning appears when rendering plugin or runner dynamic form schemas." +patterns: + - "Each child in a list should have a unique key prop" + - "DynamicFormComponent" + - "config schema" +likely_causes: + - "A plugin component schema item has name but no id." + - "The frontend maps dynamic config items using config.id as the only key." +fix_steps: + - "Prefer adding stable ids to backend metadata derived from component id and field name." + - "Keep a frontend fallback key using config.id, config.name, then list index for defensive rendering." + - "Reload the affected form and inspect console warnings." +verification: "The dynamic form renders runner/plugin fields without React unique key warnings." +related_cases: + - langrag-kb-retrieve + - local-agent-rag-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/e2b-extra-mount-sync-missing.yaml b/skills/skills/langbot-testing/troubleshooting/e2b-extra-mount-sync-missing.yaml new file mode 100644 index 0000000..c739750 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/e2b-extra-mount-sync-missing.yaml @@ -0,0 +1,23 @@ +id: e2b-extra-mount-sync-missing +title: "Activated skill files are missing or not written back on E2B" +date: 2026-05-18 +symptoms: + - "register_skill succeeds but the activated skill cannot be used from /workspace/.skills/." + - "A file written inside /workspace/.skills/ does not appear in the Box-managed skill store." + - "The same activate-and-exec flow works on Docker or nsjail but fails on E2B." +patterns: + - "No such file or directory: /workspace/.skills" + - "Skill file not found after activated skill exec" + - "E2B command runs under /home/user/workspace while LangBot paths use /workspace" +likely_causes: + - "Public E2B does not provide host bind mounts, so LangBot must upload and download mounted directories." + - "Only the main workspace mount was synced, while activated skills are passed as extra_mounts." + - "Writeback from writable extra_mounts was not downloaded after command completion." +fix_steps: + - "Sync the main host_path and each extra_mount into the adapted E2B path before command execution." + - "After command execution, sync writable host_path and writable extra_mounts back to host storage." + - "Rewrite logical /workspace command paths to E2B's internal writable path only at execution time." + - "Keep session metadata mount_path as LangBot's logical path, not the E2B-internal path." +verification: "Run sandbox-skill-authoring-e2e with BOX_BACKEND=e2b and confirm activated_writeback.txt is readable from the registered skill package after the second exec." +related_cases: + - sandbox-skill-authoring-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/local-agent-model-route-unavailable.yaml b/skills/skills/langbot-testing/troubleshooting/local-agent-model-route-unavailable.yaml new file mode 100644 index 0000000..7796544 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/local-agent-model-route-unavailable.yaml @@ -0,0 +1,35 @@ +id: local-agent-model-route-unavailable +title: "Local Agent model route is unavailable for the requested run shape" +category: env_issue +date: 2026-05-21 +symptoms: + - "Debug Chat shows Agent runner temporarily unavailable after the user message is sent." + - "Basic streaming prompts may work, but tool-call, non-streaming, or multimodal prompts fail with the same runner and pipeline." + - "The failure happens after the local-agent runner starts, not during plugin discovery." +patterns: + - "runner.llm_error" + - "runner.tool_loop_error" + - "model_not_found" + - "no available channel for model" + - "invalid api key" + - "当前分组上游负载已饱和" + - "All models failed during streaming setup" +likely_causes: + - "The selected model route is unavailable in the current LangBot Space or upstream group." + - "The selected model works for plain chat but is not available for tool-call, multimodal, or non-streaming request shapes." + - "The provider credential or quota is invalid for the non-streaming path." + - "The selected model has function-call or vision metadata, but the upstream distributor cannot currently serve that model." +fix_steps: + - "First rerun a basic local-agent Debug Chat prompt on the same pipeline to confirm the runner host path still works." + - "Switch to a known-good model for the exact request shape being tested: plain chat, tool call, multimodal, or non-streaming." + - "When tool-call cases fail, retest with a model that is known to support function calling in the active environment." + - "When multimodal cases fail, retest with a model route that is known to accept image content." + - "When non-streaming fails with invalid api key, verify the provider credential used by the non-streaming requester path." + - "Do not classify this as an MCP, RAG, or local-agent runner implementation failure until the same model route works for the requested request shape outside the failing case." +verification: "The same case produces the expected bot-visible sentinel or tool result, and backend logs show request completion without runner.llm_error, runner.tool_loop_error, or All models failed." +related_cases: + - local-agent-basic-debug-chat + - local-agent-plugin-tool-call-debug-chat + - mcp-stdio-tool-call + - local-agent-multimodal-debug-chat + - local-agent-nonstreaming-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/marketplace-network-flaky.yaml b/skills/skills/langbot-testing/troubleshooting/marketplace-network-flaky.yaml new file mode 100644 index 0000000..6493686 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/marketplace-network-flaky.yaml @@ -0,0 +1,20 @@ +id: marketplace-network-flaky +title: "Marketplace requests are flaky but plugin data may still load" +date: 2026-05-17 +symptoms: + - "Marketplace page shows failed plugin list toasts or console network errors." + - "Some plugin cards still render and can be installed." +patterns: + - "Failed to get plugin list" + - "ERR_NETWORK_CHANGED" + - "https://space.langbot.app/api/v1/marketplace" +likely_causes: + - "Transient network/proxy instability while loading marketplace search, tags, icons, releases, or recommendation lists." + - "Non-critical marketplace resource requests fail after the main plugin list has already rendered." +fix_steps: + - "Retry the Marketplace page before treating the flow as blocked." + - "If the target plugin card is visible, continue through the UI install flow." + - "Use /api/v1/plugins and /api/v1/knowledge/engines only to confirm install result." +verification: "The target plugin appears in Installed Plugins or the relevant component endpoint, such as /api/v1/knowledge/engines for LangRAG." +related_cases: + - langrag-kb-retrieve diff --git a/skills/skills/langbot-testing/troubleshooting/mcp-stdio-args-not-applied.yaml b/skills/skills/langbot-testing/troubleshooting/mcp-stdio-args-not-applied.yaml new file mode 100644 index 0000000..c079d70 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/mcp-stdio-args-not-applied.yaml @@ -0,0 +1,36 @@ +id: mcp-stdio-args-not-applied +title: "MCP Stdio test runs only the command without arguments" +date: 2026-05-17 +symptoms: + - "The MCP server Test button fails even though the same command works in a terminal." + - "Backend logs show uv help text or another bare command output instead of starting the MCP server." + - "Backend logs show uv: not found after the fixture path is accepted by Box." + - "Backend logs show host_path is outside allowed_mount_roots before the stdio process starts." + - "The task exception is Connection failed, please check URL or Connection closed." + - "Backend startup logs say qa_mcp_echo_server.py cannot be opened from the LangBot repo root." +patterns: + - "An extremely fast Python package manager." + - "Usage: uv [OPTIONS] " + - "/bin/sh: 1: uv: not found" + - "host_path is outside allowed_mount_roots" + - "McpError: Connection closed" + - "mcp-test-_" + - "can't open file '.../LangBot/qa_mcp_echo_server.py'" +likely_causes: + - "Box refuses to mount the bundled fixture directory because it is not listed in box.local.allowed_mount_roots." + - "The saved MCP server command uses uv, but uv is not available inside the Box runtime PATH." + - "The WebUI did not pass the current stdio args to the test request." + - "The MCP form exposed a stale imperative test handler from an old render." + - "The user entered the whole command line into the command field instead of splitting command and args." + - "An old qa-local-stdio database record still points at a temporary LangBot root script instead of the bundled skills fixture." +fix_steps: + - "Confirm the fixture directory is listed in box.local.allowed_mount_roots for the local LangBot data config." + - "Confirm command is only the executable, normally python for this fixture." + - "Confirm args are separate entries; for this fixture use the server script path as the single arg." + - "Confirm the fixture script is the current dependency-free qa_mcp_echo_server.py." + - "For qa-local-stdio, set the server script path to the absolute skills fixture path: skills/langbot-testing/fixtures/mcp/qa_mcp_echo_server.py." + - "If the UI still sends empty args, fix the MCP form test handler so it reads the latest state." + - "Retest with the bundled qa_mcp_echo_server.py fixture." +verification: "The MCP test task completes without exception, the saved server shows connected, and /api/v1/tools contains qa_mcp_echo." +related_cases: + - mcp-stdio-tool-call diff --git a/skills/skills/langbot-testing/troubleshooting/nsjail-cli-compatibility.yaml b/skills/skills/langbot-testing/troubleshooting/nsjail-cli-compatibility.yaml new file mode 100644 index 0000000..97f752c --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/nsjail-cli-compatibility.yaml @@ -0,0 +1,29 @@ +id: nsjail-cli-compatibility +title: "Installed nsjail CLI rejects generated sandbox arguments" +date: 2026-05-18 +symptoms: + - "Box status reports nsjail as available, but every command exits before the user command runs." + - "Direct nsjail --help works, but LangBot exec fails." + - "On WSL, cgroup v2 warnings appear before falling back to rlimits." +patterns: + - "nsjail: unrecognized option '--clone_newuser'" + - "nsjail: unrecognized option '--clone_newnet'" + - "nsjail: unrecognized option '--rw_bind'" + - "execve('sh') failed: No such file or directory" + - "Failed to mount mandatory point: '/workspace'" + - "createCgroup() ... failed" +likely_causes: + - "The installed nsjail version enables clone namespaces by default and exposes only disable flags." + - "The installed nsjail uses --bindmount for read-write bind mounts, not --rw_bind." + - "A bare chroot at / cannot create mount targets for /workspace." + - "The command uses sh instead of /bin/sh inside the chroot." + - "cgroup v2 exists but is not writable by the LangBot user." +fix_steps: + - "Generate no positive --clone_new* flags; use --disable_clone_newnet only when network is requested." + - "Use --bindmount for read-write mounts and --bindmount_ro for read-only mounts." + - "Create a per-session chroot root and pre-create mount target directories such as /workspace, /tmp, /home, and activated skill paths." + - "Execute commands through /bin/sh -lc." + - "Check cgroup v2 writability before using cgroup flags; warn and use rlimits when not writable." +verification: "Run sandbox-skill-authoring-e2e with BOX_BACKEND=nsjail. The model should complete exec, register_skill, activate, and activated skill exec without nsjail argument errors." +related_cases: + - sandbox-skill-authoring-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/pipeline-form-controlled-warning.yaml b/skills/skills/langbot-testing/troubleshooting/pipeline-form-controlled-warning.yaml new file mode 100644 index 0000000..92f788a --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/pipeline-form-controlled-warning.yaml @@ -0,0 +1,20 @@ +id: pipeline-form-controlled-warning +title: "Pipeline form input switches from uncontrolled to controlled" +date: 2026-05-17 +symptoms: + - "Browser console shows a React uncontrolled input to controlled input warning." + - "The warning appears after opening a pipeline edit page or loading pipeline data." +patterns: + - "A component is changing an uncontrolled input to be controlled" + - "PipelineFormComponent" +likely_causes: + - "React Hook Form default values omit string fields that later receive loaded values." + - "Input value receives undefined before the backend response arrives." +fix_steps: + - "Initialize pipeline basic fields such as name and description to empty strings." + - "When loading backend data, coalesce nullable string values to empty strings." + - "Pass value={field.value ?? ''} for text inputs that can render before data loads." +verification: "Opening the pipeline edit page and AI config produces no uncontrolled/controlled input warning." +related_cases: + - pipeline-debug-chat + - local-agent-rag-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/plugin-dependency-install-offline.yaml b/skills/skills/langbot-testing/troubleshooting/plugin-dependency-install-offline.yaml new file mode 100644 index 0000000..f5a484d --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/plugin-dependency-install-offline.yaml @@ -0,0 +1,24 @@ +id: plugin-dependency-install-offline +title: "Local plugin dependency install fails in an offline or proxy-limited runtime" +date: 2026-05-17 +symptoms: + - "Installing a local .lbpkg starts but fails during Installing Dependencies." + - "The plugin files may appear under data/plugins, but the plugin is not initialized or listed as active." +patterns: + - "Failed to install dependency: PyMuPDF>=1.24.0" + - "ERROR: Could not find a version that satisfies the requirement PyMuPDF" + - "ERROR: No matching distribution found for PyMuPDF" + - "Installing langbot-team-GeneralParsers" +likely_causes: + - "The LangBot plugin runtime is started without a working network path to PyPI or the configured package index." + - "The backend was started with proxy variables unset to avoid SOCKS-related HTTP client errors, so plugin dependency installation cannot download uncached packages." + - "The dependency is available in another local venv or cache, but not in the active LangBot master venv used by the plugin runtime." +fix_steps: + - "Confirm the active runtime venv: use the same LangBot master .venv that starts the backend." + - "Before retrying local plugin install, verify required dependencies with python -m pip install --dry-run ''." + - "If the environment is offline, install from an existing local wheel/cache or another trusted local venv into the active LangBot master venv." + - "Retry the UI Upload Local install and wait for Plugin installed successfully." + - "For GeneralParsers, confirm import fitz works in the active LangBot master venv before retrying." +verification: "Installed Plugins shows the local plugin initialized, and backend logs include Plugin langbot-team/GeneralParsers initialized." +related_cases: + - langrag-parser-golden-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/plugin-runtime-timeout.yaml b/skills/skills/langbot-testing/troubleshooting/plugin-runtime-timeout.yaml new file mode 100644 index 0000000..3b0f33f --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/plugin-runtime-timeout.yaml @@ -0,0 +1,31 @@ +id: plugin-runtime-timeout +title: "Plugin runtime actions time out" +date: 2026-05-16 +symptoms: + - "Debug Chat can send a user message, but no useful Bot response appears." + - "The page may show Agent runner temporarily unavailable." + - "Installed Plugins may show Plugin System Connection Error." + - "Knowledge sidebar or plugin sidebar loading may hang or time out." +patterns: + - "Action list_plugins call timed out" + - "Action list_agent_runners call timed out" + - "Action invoke_llm_stream call timed out" + - "All models failed during streaming setup" + - "Failed to fetch plugins for sidebar" + - "Failed to fetch knowledge bases for sidebar" +likely_causes: + - "An old langbot_plugin runtime process survived a backend restart." + - "Multiple plugin runtime processes are active and the backend is waiting on the wrong one." + - "Plugin runtime got a control connection but never finished plugin discovery or launch." + - "A disposable test plugin in data/plugins is blocking runtime startup." +fix_steps: + - "Stop the LangBot backend." + - "Stop orphaned langbot_plugin.cli runtime processes." + - "Confirm the configured backend URL is free or reachable as appropriate." + - "Start LangBot again and wait for Connected to plugin runtime plus langbot/local-agent initialization." + - "For disposable test data only, move suspected fixture plugins out of data/plugins and restart to isolate plugin discovery failures." +verification: "Open Installed Plugins and confirm the plugin list loads, then run pipeline-debug-chat and confirm the UI shows a Bot response while backend logs include Streaming completed." +related_cases: + - pipeline-debug-chat + - provider-deepseek + - langrag-parser-golden-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/provider-image-parse-error.yaml b/skills/skills/langbot-testing/troubleshooting/provider-image-parse-error.yaml new file mode 100644 index 0000000..4ddbcfe --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/provider-image-parse-error.yaml @@ -0,0 +1,23 @@ +id: provider-image-parse-error +title: "Model provider rejects the uploaded image before runner behavior is exercised" +date: 2026-05-17 +symptoms: + - "Debug Chat shows Agent runner temporarily unavailable after an image upload." + - "Backend logs include image_parse_error or unsupported image from the model provider." + - "The upload endpoint succeeds, but the LLM request fails before a useful model response is returned." +patterns: + - "image_parse_error" + - "unsupported image" + - "You uploaded an unsupported image" + - "runner.llm_error" +likely_causes: + - "The fixture image is too small or otherwise rejected by the active model provider." + - "The selected model or provider route does not support image input even though the WebUI upload succeeded." + - "The provider reports image validation errors as rate-limit or requester errors." +fix_steps: + - "Retest with a normal-sized PNG such as the bundled 64x64 red-square fixture." + - "Switch to a model route known to accept image input before diagnosing local-agent multimodal handling." + - "Use backend logs to separate provider image parsing failure from runner context or message-building failure." +verification: "The same Debug Chat image prompt reaches the model without image_parse_error; if semantic vision is unreliable, treat UI image visibility plus message-building tests as the runner coverage signal." +related_cases: + - local-agent-multimodal-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/proxy-env-mismatch.yaml b/skills/skills/langbot-testing/troubleshooting/proxy-env-mismatch.yaml new file mode 100644 index 0000000..b9b398e --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/proxy-env-mismatch.yaml @@ -0,0 +1,22 @@ +id: proxy-env-mismatch +title: "Uppercase and lowercase proxy variables differ" +date: 2026-05-16 +symptoms: + - "External model calls time out even though a proxy appears to be configured." + - "GitHub OAuth or provider tests fail intermittently from the agent environment." +patterns: + - "HTTP_PROXY and http_proxy point to different hosts" + - "HTTPS_PROXY and https_proxy point to different hosts" + - "ALL_PROXY and all_proxy point to different hosts" + - "old WSL gateway proxy remains in lowercase variables" +likely_causes: + - "Different HTTP clients read different proxy environment variable names." + - "The shell inherited stale lowercase proxy values from an older session." +fix_steps: + - "Set HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, http_proxy, https_proxy, all_proxy, NO_PROXY, and no_proxy consistently." + - "Restart LangBot with the corrected proxy environment." +verification: "Run env | rg -i '^(http|https|all|no)_?proxy=' and confirm uppercase/lowercase pairs are consistent, then run pipeline-debug-chat." +related_cases: + - pipeline-debug-chat + - provider-deepseek + - webui-login-state diff --git a/skills/skills/langbot-testing/troubleshooting/sandbox-native-tools-unavailable.yaml b/skills/skills/langbot-testing/troubleshooting/sandbox-native-tools-unavailable.yaml new file mode 100644 index 0000000..6c61060 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/sandbox-native-tools-unavailable.yaml @@ -0,0 +1,24 @@ +id: sandbox-native-tools-unavailable +title: "Native sandbox tools are unavailable even though a backend is configured" +date: 2026-05-18 +symptoms: + - "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available." + - "The Box runtime later reports that E2B, nsjail, or Docker is configured." + - "Debug Chat does not expose exec, register_skill, or activate as usable tools." +patterns: + - "Native sandbox tools ... are NOT available" + - "No sandbox backend (Docker/nsjail/E2B) is ready" + - "box.backend is set but /api/v1/box/status backend is unavailable" +likely_causes: + - "Backend status was checked before the Box runtime selected or reselected a backend." + - "The configured backend failed availability checks because credentials, binary, Docker daemon, or proxy settings were not ready." + - "The runtime cached an unavailable backend state after INIT config changed the backend." +fix_steps: + - "Check /api/v1/box/status and verify backend.name and backend.available." + - "Restart LangBot after fixing backend credentials, binary installation, Docker daemon, or proxy settings." + - "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty." + - "For E2B, verify the key without printing it and confirm any required template setting." + - "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process." +verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend." +related_cases: + - sandbox-skill-authoring-e2e diff --git a/skills/skills/langbot-testing/troubleshooting/socks-proxy-without-socksio.yaml b/skills/skills/langbot-testing/troubleshooting/socks-proxy-without-socksio.yaml new file mode 100644 index 0000000..b3e6d7b --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/socks-proxy-without-socksio.yaml @@ -0,0 +1,24 @@ +id: socks-proxy-without-socksio +title: "Python HTTP clients fail when ALL_PROXY uses SOCKS without socksio" +date: 2026-05-18 +symptoms: + - "LangBot startup fails while importing or initializing a provider client." + - "The failure happens before sandbox testing begins." + - "Clearing ALL_PROXY lets LangBot start, but external HTTP calls may then need another proxy path." +patterns: + - "Using SOCKS proxy, but the 'socksio' package is not installed" + - "httpx using SOCKS proxy" + - "ALL_PROXY=socks5://" +likely_causes: + - "The process inherited ALL_PROXY or all_proxy with a SOCKS URL." + - "A dependency uses httpx without the socks extra installed." + - "HTTP_PROXY and HTTPS_PROXY would have been sufficient, but ALL_PROXY took precedence for that client." +fix_steps: + - "Start LangBot with ALL_PROXY and all_proxy unset when SOCKS support is not installed." + - "Keep HTTP_PROXY and HTTPS_PROXY set consistently if an HTTP proxy is available." + - "Alternatively install the dependency's SOCKS support in the runtime environment." + - "Keep NO_PROXY/no_proxy covering localhost, 127.0.0.1, and ::1." +verification: "LangBot starts cleanly, model provider calls still use the intended proxy path, and sandbox-skill-authoring-e2e can reach the model provider." +related_cases: + - sandbox-skill-authoring-e2e + - provider-deepseek diff --git a/skills/skills/langbot-testing/troubleshooting/survey-widget-blocks-debug-chat.yaml b/skills/skills/langbot-testing/troubleshooting/survey-widget-blocks-debug-chat.yaml new file mode 100644 index 0000000..f70ce63 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/survey-widget-blocks-debug-chat.yaml @@ -0,0 +1,22 @@ +id: survey-widget-blocks-debug-chat +title: "Survey widget blocks Debug Chat controls" +date: 2026-05-17 +symptoms: + - "Debug Chat input is filled but the Send button cannot be clicked." + - "Playwright reports that a fixed bottom-right widget intercepts pointer events." +patterns: + - "subtree intercepts pointer events" + - "Help us improve" + - "fixed bottom-6 right-6" +likely_causes: + - "The survey widget overlaps the Debug Chat action area at the current viewport size." + - "The test viewport is narrow enough that the floating widget covers the Send button." +fix_steps: + - "Close or minimize the survey widget before interacting with Debug Chat." + - "Alternatively, resize the browser wider or use keyboard submission if the UI supports it." + - "Continue the browser test after confirming the widget is gone." +verification: "The Send button can be clicked and Debug Chat shows both the User message and Bot response." +related_cases: + - pipeline-debug-chat + - local-agent-rag-debug-chat + - mcp-stdio-tool-call diff --git a/skills/skills/langbot-testing/troubleshooting/telemetry-proxy-noise.yaml b/skills/skills/langbot-testing/troubleshooting/telemetry-proxy-noise.yaml new file mode 100644 index 0000000..9451090 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/telemetry-proxy-noise.yaml @@ -0,0 +1,23 @@ +id: telemetry-proxy-noise +title: "Telemetry posting fails through the proxy while the target flow succeeds" +date: 2026-06-25 +category: env_issue +symptoms: + - "The target Debug Chat or provider smoke request completes successfully." + - "The same log window contains a Traceback for telemetry posting." + - "The traceback references the Space telemetry endpoint." +patterns: + - "Failed to post telemetry" + - "https://space.langbot.app/api/v1/telemetry" + - "httpx.ConnectError" +likely_causes: + - "The backend process inherited proxy settings that are required for model/provider access but unreliable for telemetry posting." + - "The telemetry endpoint is temporarily unreachable through the local proxy route." + - "TLS or proxy negotiation failed for the non-critical telemetry request." +fix_steps: + - "Keep the proxy configuration needed for model/provider access; do not clear it only to hide telemetry noise." + - "Check that uppercase and lowercase proxy variables are consistent before rerunning a live Space smoke." + - "Classify the target flow and log-health result separately: a successful Debug Chat run can still have an environment log-health finding." +verification: "A rerun shows the target case success patterns and no telemetry Traceback in the scanned log window, or the report explicitly records the telemetry issue as environment noise." +related_cases: + - langbot-space-debug-chat-concurrency-smoke diff --git a/skills/skills/langbot-testing/troubleshooting/tool-name-collision-between-mcp-and-plugin.yaml b/skills/skills/langbot-testing/troubleshooting/tool-name-collision-between-mcp-and-plugin.yaml new file mode 100644 index 0000000..ab40b04 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/tool-name-collision-between-mcp-and-plugin.yaml @@ -0,0 +1,25 @@ +id: tool-name-collision-between-mcp-and-plugin +title: "MCP and plugin expose the same tool name" +date: 2026-05-21 +symptoms: + - "An MCP tool-call case completes, but the returned sentinel has a plugin prefix instead of the MCP prefix." + - "The prompt asks for the MCP fixture tool, but the bot returns qa-plugin-smoke:." + - "GET /api/v1/tools or the runner tool list contains multiple tools with the same visible name." +patterns: + - "qa-plugin-smoke:mcp-ok-local-agent" + - "multiple tools with the same visible name" + - "duplicate tool name" + - "qa/plugin-smoke" +likely_causes: + - "A stale MCP registration still exposes the old qa_echo fixture name while qa-plugin-smoke is installed." + - "The model selects the plugin tool because both tools have the same call name or similar descriptions." + - "The test prompt names an ambiguous tool, so the browser result cannot prove which provider executed the tool unless the returned sentinel is checked." +fix_steps: + - "Use unique tool names across simultaneous test fixtures. The current MCP fixture should expose qa_mcp_echo, not qa_echo." + - "When testing MCP, require the bot response to contain the MCP-specific sentinel qa_mcp_echo:, not qa-plugin-smoke:." + - "When testing plugin tools, prefer qa_plugin_echo or another plugin-only tool name that cannot be confused with the MCP fixture." + - "Refresh the MCP server registration or restart the backend if /api/v1/tools still shows the old qa_echo MCP fixture." +verification: "The MCP case passes only when the bot-visible response contains the MCP fixture sentinel and the plugin-only sentinel is absent for the same input." +related_cases: + - mcp-stdio-tool-call + - local-agent-plugin-tool-call-debug-chat diff --git a/skills/skills/langbot-testing/troubleshooting/uv-run-resyncs-local-sdk.yaml b/skills/skills/langbot-testing/troubleshooting/uv-run-resyncs-local-sdk.yaml new file mode 100644 index 0000000..e05a2f0 --- /dev/null +++ b/skills/skills/langbot-testing/troubleshooting/uv-run-resyncs-local-sdk.yaml @@ -0,0 +1,22 @@ +id: uv-run-resyncs-local-sdk +title: "uv run resyncs the locked SDK instead of the local editable SDK" +date: 2026-05-17 +symptoms: + - "LangBot was expected to use a local langbot-plugin SDK checkout, but import inspection points to .venv/site-packages." + - "uv output shows langbot-plugin was uninstalled and installed again immediately before a test or startup command." + - "A test appears to pass but is actually using the PyPI SDK from uv.lock." +patterns: + - "uv run python -c import langbot_plugin prints .venv/lib/python*/site-packages/langbot_plugin" + - "Uninstalled 1 package" + - "Installed 1 package" + - "langbot-plugin==0.3.11 without file:/// local source" +likely_causes: + - "Plain uv run synchronizes the environment to uv.lock before executing the command." + - "The editable local SDK was installed with uv pip install -e, but the next uv run reverted it." +fix_steps: + - "Install the local SDK into the LangBot worktree with uv pip install -e /absolute/path/to/langbot-plugin-sdk-test-build." + - "Run validation and startup commands with uv run --no-sync." + - "Before trusting results, print pathlib.Path(langbot_plugin.__file__).resolve() and confirm it points to the local SDK source tree." +verification: "uv run --no-sync python -c import inspection prints the local SDK source path, and plugin-e2e-smoke passes with that same process environment." +related_cases: + - plugin-e2e-smoke diff --git a/skills/src/cli.ts b/skills/src/cli.ts new file mode 100644 index 0000000..27a59cf --- /dev/null +++ b/skills/src/cli.ts @@ -0,0 +1,120 @@ +import { dirname, resolve } from "node:path"; +import { cwd, exit } from "node:process"; +import { existsSync } from "node:fs"; +import type { CommandContext } from "./types.ts"; + +export function usage(): never { + console.log(`Usage: + bin/lbs [--root ] list + bin/lbs [--root ] validate + bin/lbs [--root ] index [--check] + bin/lbs [--root ] new-skill [--description ] + bin/lbs [--root ] new-ref + + bin/lbs [--root ] env show [--json] + bin/lbs [--root ] env doctor + + bin/lbs [--root ] fixture list [skill] [--json] + bin/lbs [--root ] fixture check [skill] [--json] + + bin/lbs [--root ] log scan [--json] [--output ] [--backend-log ] [--frontend-log ] [--console-log ] [--case ] [--success-pattern ] [--failure-pattern ] [--expected-failure ] [--since ] [--until ] [--tail-lines ] [--no-auto-log] [--strict] + bin/lbs [--root ] log watch [--json] [--backend-log ] [--case ] [--success-pattern ] [--failure-pattern ] [--expected-failure ] [--interval-ms ] [--duration-ms ] [--from-start] [--strict] + bin/lbs [--root ] log guard start [--run-id ] [--output-dir ] [--backend-log ] [--case ] [--json] + bin/lbs [--root ] log guard stop --run-id [--output-dir ] [--session ] [--output ] [--case ] [--backend-log ] [--since ] [--until ] [--json] [--no-strict] + + bin/lbs [--root ] case new --title [--skill langbot-testing] [--mode agent-browser|probe] [--area ] [--type smoke] + bin/lbs [--root ] case list [skill] [--json] [--type ] [--area ] [--tag ] [--priority p0|p1|p2] [--risk low|medium|high] [--automation] [--ci] [--ready] [--machine-ready] + bin/lbs [--root ] case show [skill] + + bin/lbs [--root ] suite new --title [--skill langbot-testing] [--description ] [--type smoke] [--priority p2] + bin/lbs [--root ] suite list [skill] [--json] [--type ] [--priority p0|p1|p2] + bin/lbs [--root ] suite show [skill] + bin/lbs [--root ] suite plan [skill] [--json] + bin/lbs [--root ] suite start [skill] [--run-id ] [--evidence-dir ] [--output ] [--json] + bin/lbs [--root ] suite run [skill] [--run-id ] [--evidence-dir ] [--output ] [--headed] [--dry-run] [--include-manual-check] [--include-not-ready] [--json] + bin/lbs [--root ] suite report [skill] [--run-id ] [--evidence-dir ] [--output ] [--json] + + bin/lbs [--root ] test plan [skill] [--json] + bin/lbs [--root ] test recommend [--file ] [--json] + bin/lbs [--root ] test start [skill] [--output ] [--json] + bin/lbs [--root ] test run [skill] [--output ] [--run-id ] [--headed] [--dry-run] [--json] + bin/lbs [--root ] test report [skill] [--output ] [--json] [--backend-log ] [--frontend-log ] [--console-log ] [--evidence-dir ] [--since ] [--until ] [--tail-lines ] [--no-auto-log] + bin/lbs [--root ] test result [skill] --result --reason --evidence-dir [--evidence ui,console,backend_log] [--started-at ] [--finished-at ] [--run-id ] [--url ] [--browser-path ] [--report ] [--notes ] [--json] + + bin/lbs [--root ] trouble list [skill] + bin/lbs [--root ] trouble show [skill] + bin/lbs [--root ] trouble search + bin/lbs [--root ] trouble add --title --symptom --cause --fix [--id ] [--verify ] +`); + exit(2); +} + +export function fail(message: string): never { + console.error(`ERROR: ${message}`); + exit(1); +} + +export function repoRoot(start: string): string { + let current = resolve(start); + while (true) { + // The skills assets root is identified by skills.index.json (present at the + // root of this assets tree). Check it first so that when the tree lives + // inside a larger repo (e.g. LangBot/skills/), we stop at the assets root + // and not at the outer repo's .git/README.md. + if (existsSync(`${current}/skills.index.json`) && existsSync(`${current}/schemas/case.schema.json`)) { + return current; + } + if (existsSync(`${current}/.git`) && existsSync(`${current}/README.md`)) { + return current; + } + const parent = dirname(current); + if (parent === current) return resolve(start); + current = parent; + } +} + +export function parseGlobalArgs(rawArgs: string[]): CommandContext { + let root = repoRoot(cwd()); + const args = [...rawArgs]; + + for (let i = 0; i < args.length; ) { + if (args[i] === "--root") { + const value = args[i + 1]; + if (!value) fail("--root requires a path"); + root = resolve(value); + args.splice(i, 2); + continue; + } + i += 1; + } + + return { root, args }; +} + +export function parseOptions(args: string[]): { positional: string[]; options: Record } { + const positional: string[] = []; + const options: Record = {}; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg.startsWith("--")) { + const key = arg.slice(2); + const value = args[i + 1]; + if (!value || value.startsWith("--")) { + options[key] = true; + } else { + options[key] = value; + i += 1; + } + } else { + positional.push(arg); + } + } + + return { positional, options }; +} + +export function optionString(options: Record, key: string): string | undefined { + const value = options[key]; + return typeof value === "string" ? value : undefined; +} diff --git a/skills/src/commands/case.ts b/skills/src/commands/case.ts new file mode 100644 index 0000000..eeec837 --- /dev/null +++ b/skills/src/commands/case.ts @@ -0,0 +1,180 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { CommandContext } from "../types.ts"; +import { parseOptions, optionString, usage, fail } from "../cli.ts"; +import { caseModeValues } from "../constants.ts"; +import { boolValue, findStructuredItem, getSkill, listValue, loadStructuredItems, scalar, yamlList, yamlQuote } from "../fs.ts"; +import { caseAutomationReadiness, caseEnvReadiness, caseFixtureReadiness, caseManualReadiness, runtimeEnv } from "../readiness.ts"; +import { setupAutomationEntries } from "../setup-automation.ts"; + +function casePath(root: string, skillName: string, id: string): string { + const skill = getSkill(root, skillName); + const dir = join(skill.path, "cases"); + mkdirSync(dir, { recursive: true }); + return join(dir, `${id}.yaml`); +} + +export function commandCaseNew(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const id = positional[0]; + const title = optionString(options, "title"); + if (!id || !title) usage(); + + const skill = optionString(options, "skill") ?? "langbot-testing"; + const path = casePath(ctx.root, skill, id); + if (existsSync(path)) fail(`case already exists: ${path}`); + + const area = optionString(options, "area") ?? "general"; + const type = optionString(options, "type") ?? "smoke"; + const mode = optionString(options, "mode") ?? "agent-browser"; + if (!caseModeValues.includes(mode)) fail(`--mode must be one of ${caseModeValues.join(", ")}`); + const isProbe = mode === "probe"; + + const text = + `id: ${id}\n` + + `title: ${yamlQuote(title)}\n` + + `mode: ${mode}\n` + + `area: ${area}\n` + + `type: ${type}\n` + + "priority: p2\n" + + "risk: medium\n" + + "ci_eligible: false\n" + + "tags:\n" + + yamlList([type]) + + "\nskills:\n" + + yamlList(["langbot-env-setup", skill]) + + "\nenv:\n" + + yamlList(isProbe ? [] : ["LANGBOT_FRONTEND_URL", "LANGBOT_BACKEND_URL"]) + + "\nsteps:\n" + + yamlList([isProbe ? "Describe the probe command, script, or diagnostic to run." : "Describe the user-visible action to perform."]) + + "\nchecks:\n" + + yamlList(isProbe + ? [ + "Probe: Describe the expected success signal.", + "Evidence: Required logs, API diagnostics, or filesystem artifacts are written.", + ] + : [ + "UI: Describe the user-visible success signal.", + "Console: No unexpected frontend errors.", + "Logs: Relevant backend processing completed when applicable.", + ]) + + "\nevidence_required:\n" + + yamlList(isProbe ? ["api_diagnostic"] : ["ui", "console"]) + + "\ndiagnostics:\n" + + yamlList([isProbe + ? "Use logs, API, or filesystem diagnostics to explain probe failures." + : "Use API/curl/logs only to distinguish frontend failure from backend/runtime failure."]) + + "\ntroubleshooting:\n" + + yamlList([]) + + "\n"; + + writeFileSync(path, text, "utf8"); + console.log(path); + return 0; +} + +function caseRow(item: ReturnType[number], root: string): Record { + const automation = scalar(item.fields, "automation"); + const env = runtimeEnv(root); + const id = scalar(item.fields, "id"); + const row = { + skill: item.skill, + id, + title: scalar(item.fields, "title"), + mode: scalar(item.fields, "mode"), + area: scalar(item.fields, "area"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + risk: scalar(item.fields, "risk"), + ci_eligible: boolValue(item.fields, "ci_eligible") ?? false, + tags: listValue(item.fields, "tags"), + env: listValue(item.fields, "env"), + env_any: listValue(item.fields, "env_any"), + preconditions: listValue(item.fields, "preconditions"), + setup: listValue(item.fields, "setup"), + setup_automation: setupAutomationEntries(item), + setup_provides_env: listValue(item.fields, "setup_provides_env"), + cleanup: listValue(item.fields, "cleanup"), + evidence_required: listValue(item.fields, "evidence_required"), + automation, + automation_exists: automation ? existsSync(resolve(root, automation)) : false, + env_readiness: caseEnvReadiness(item, env), + automation_readiness: caseAutomationReadiness(item, env), + fixture_readiness: caseFixtureReadiness(root, id), + manual_readiness: caseManualReadiness(item), + }; + return { + ...row, + readiness: readinessLabel(row), + }; +} + +function hasTag(row: Record, tag: string): boolean { + const tags = row.tags; + return Array.isArray(tags) && tags.includes(tag); +} + +function hasMissingReadiness(row: Record): boolean { + for (const key of ["env_readiness", "automation_readiness", "fixture_readiness"]) { + const value = row[key] as Record | undefined; + if (value?.status === "missing") return true; + } + return false; +} + +function hasManualCheck(row: Record): boolean { + const manual = row.manual_readiness as Record | undefined; + return manual?.status === "manual_check"; +} + +function readinessLabel(row: Record): string { + if (hasMissingReadiness(row)) return "not-ready"; + return hasManualCheck(row) ? "manual-check" : "ready"; +} + +export function commandCaseList(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const skill = positional[0]; + const rows = loadStructuredItems(ctx.root, "cases", skill) + .map((item) => caseRow(item, ctx.root)) + .filter((row) => !optionString(options, "type") || row.type === optionString(options, "type")) + .filter((row) => !optionString(options, "area") || row.area === optionString(options, "area")) + .filter((row) => !optionString(options, "priority") || row.priority === optionString(options, "priority")) + .filter((row) => !optionString(options, "risk") || row.risk === optionString(options, "risk")) + .filter((row) => !optionString(options, "tag") || hasTag(row, optionString(options, "tag") ?? "")) + .filter((row) => options.automation !== true || Boolean(row.automation)) + .filter((row) => options.ci !== true || row.ci_eligible === true) + .filter((row) => options["machine-ready"] !== true || !hasMissingReadiness(row)) + .filter((row) => options.ready !== true || (!hasMissingReadiness(row) && !hasManualCheck(row))); + + if (options.json === true) { + console.log(JSON.stringify(rows, null, 2)); + return 0; + } + + for (const row of rows) { + console.log([ + row.skill, + row.id, + row.type, + row.area, + row.priority, + row.risk, + row.ci_eligible ? "ci" : "manual", + row.automation ? "automated" : "manual-path", + row.readiness, + row.title, + ].join("\t")); + } + return 0; +} + +export function commandCaseShow(ctx: CommandContext): number { + const positional = ctx.args.slice(2); + if (positional.length < 1 || positional.length > 2) usage(); + const item = positional.length === 1 + ? findStructuredItem(ctx.root, "cases", positional[0]) + : findStructuredItem(ctx.root, "cases", positional[0], positional[1]); + console.log(item.raw.trimEnd()); + return 0; +} diff --git a/skills/src/commands/env.ts b/skills/src/commands/env.ts new file mode 100644 index 0000000..76ef33a --- /dev/null +++ b/skills/src/commands/env.ts @@ -0,0 +1,175 @@ +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { Socket } from "node:net"; +import { join } from "node:path"; +import type { CommandContext } from "../types.ts"; +import { parseOptions } from "../cli.ts"; +import { loadEnv } from "../fs.ts"; +import { requiredEnvKeys } from "../constants.ts"; +import { redactEnvValue } from "../readiness.ts"; + +export function commandEnvShow(ctx: CommandContext): number { + const { options } = parseOptions(ctx.args.slice(2)); + const env = loadEnv(ctx.root); + const outputEnv = Object.fromEntries( + Object.entries(env).map(([key, value]) => [key, redactEnvValue(key, value)]), + ); + if (options.json === true) { + console.log(JSON.stringify(outputEnv, null, 2)); + return 0; + } + for (const key of Object.keys(outputEnv).sort()) { + console.log(`${key}=${outputEnv[key]}`); + } + return 0; +} + +async function checkUrl(label: string, url: string): Promise<{ ok: boolean; message: string }> { + if (!url) return { ok: false, message: `${label}: missing` }; + const displayUrl = redactEnvValue(label, url); + try { + const response = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(2500) }); + return { ok: response.ok || response.status < 500, message: `${label}: ${displayUrl} -> HTTP ${response.status}` }; + } catch (error) { + return { ok: false, message: `${label}: ${displayUrl} -> ${String(error).replace(/\s+/g, " ")}` }; + } +} + +function endpoint(url: string): { host: string; port: number } | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + const port = parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80; + return { host: parsed.hostname, port }; + } catch { + return null; + } +} + +async function checkTcpListener(url: string): Promise<{ ok: boolean; message: string } | null> { + const target = endpoint(url); + if (!target) return null; + + return await new Promise((resolve) => { + const socket = new Socket(); + let settled = false; + const finish = (ok: boolean, detail: string) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve({ + ok, + message: `${target.host}:${target.port} ${detail}`, + }); + }; + + socket.setTimeout(1500); + socket.once("connect", () => finish(true, "is listening")); + socket.once("timeout", () => finish(false, "did not accept TCP connection before timeout")); + socket.once("error", (error) => finish(false, `is not listening (${error.message})`)); + socket.connect(target.port, target.host); + }); +} + +function startupHint(label: string, env: Record): string | null { + if (label === "LANGBOT_BACKEND_URL" && env.LANGBOT_REPO) { + return `start backend: cd ${env.LANGBOT_REPO} && uv run main.py`; + } + if (label === "LANGBOT_FRONTEND_URL" && env.LANGBOT_WEB_REPO) { + return `start frontend: cd ${env.LANGBOT_WEB_REPO} && pnpm dev`; + } + return null; +} + +function compareProxyPair(env: Record, upper: string, lower: string): string | null { + const upperValue = process.env[upper] ?? env[upper] ?? ""; + const lowerValue = process.env[lower] ?? env[lower] ?? ""; + if (upperValue && lowerValue && upperValue !== lowerValue) { + return `${upper}/${lower}: mismatch (${redactEnvValue(upper, upperValue)} vs ${redactEnvValue(lower, lowerValue)})`; + } + return null; +} + +function envValue(env: Record, key: string): string { + return process.env[key] ?? env[key] ?? ""; +} + +function activeSocksProxy(env: Record): { key: string; value: string } | null { + for (const key of ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]) { + const value = envValue(env, key); + if (/^socks/i.test(value)) return { key, value }; + } + return null; +} + +function checkSocksio(env: Record): string | null { + const proxy = activeSocksProxy(env); + if (!proxy) return null; + + const repo = env.LANGBOT_REPO; + const python = repo ? join(repo, ".venv", "bin", "python") : ""; + if (!python || !existsSync(python)) { + return `SOCKS proxy ${proxy.key} is configured (${redactEnvValue(proxy.key, proxy.value)}), but LangBot venv python was not found; after creating the venv, verify it can import socksio.`; + } + + const result = spawnSync(python, ["-c", "import socksio"], { + encoding: "utf8", + timeout: 5000, + }); + if (result.status === 0) return null; + + return `SOCKS proxy ${proxy.key} is configured (${redactEnvValue(proxy.key, proxy.value)}), but ${python} cannot import socksio; run \`${python} -m pip install socksio\` or start LangBot without SOCKS proxy env.`; +} + +export async function commandEnvDoctor(ctx: CommandContext): Promise { + const env = loadEnv(ctx.root); + const failures: string[] = []; + const warnings: string[] = []; + + for (const key of requiredEnvKeys) { + if (!env[key]) failures.push(`missing ${key}`); + } + + for (const [label, path] of [ + ["LANGBOT_REPO", env.LANGBOT_REPO], + ["LANGBOT_WEB_REPO", env.LANGBOT_WEB_REPO], + ["LANGBOT_CHROMIUM_EXECUTABLE", env.LANGBOT_CHROMIUM_EXECUTABLE], + ]) { + if (!path || !existsSync(path)) failures.push(`${label}: path does not exist (${path || "missing"})`); + } + + if (env.LANGBOT_BROWSER_PROFILE && !existsSync(env.LANGBOT_BROWSER_PROFILE)) { + warnings.push(`LANGBOT_BROWSER_PROFILE: path does not exist yet (${env.LANGBOT_BROWSER_PROFILE})`); + } + + for (const mismatch of [ + compareProxyPair(env, "HTTP_PROXY", "http_proxy"), + compareProxyPair(env, "HTTPS_PROXY", "https_proxy"), + compareProxyPair(env, "ALL_PROXY", "all_proxy"), + compareProxyPair(env, "NO_PROXY", "no_proxy"), + ]) { + if (mismatch) failures.push(mismatch); + } + const socksioFailure = checkSocksio(env); + if (socksioFailure) failures.push(socksioFailure); + + for (const [label, result] of await Promise.all([ + checkUrl("LANGBOT_BACKEND_URL", env.LANGBOT_BACKEND_URL).then((result) => ["LANGBOT_BACKEND_URL", result] as const), + checkUrl("LANGBOT_FRONTEND_URL", env.LANGBOT_FRONTEND_URL).then((result) => ["LANGBOT_FRONTEND_URL", result] as const), + ])) { + if (result.ok) console.log(`OK: ${result.message}`); + else { + failures.push(result.message); + const tcp = await checkTcpListener(env[label]); + if (tcp && !tcp.ok) failures.push(`${label}: no HTTP service reachable because ${tcp.message}`); + const hint = startupHint(label, env); + if (hint) warnings.push(`${label}: ${hint}`); + } + } + + for (const warning of warnings) console.log(`WARN: ${warning}`); + for (const failure of failures) console.log(`FAIL: ${failure}`); + if (failures.length > 0) return 1; + console.log("OK: environment looks usable"); + return 0; +} diff --git a/skills/src/commands/fixture.ts b/skills/src/commands/fixture.ts new file mode 100644 index 0000000..6c5bf72 --- /dev/null +++ b/skills/src/commands/fixture.ts @@ -0,0 +1,132 @@ +import type { CommandContext } from "../types.ts"; +import { parseOptions } from "../cli.ts"; +import { loadFixtureItems } from "../fixtures.ts"; +import { dirname, join } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; + +function fixtureRows(root: string, skill: string | undefined): ReturnType { + return loadFixtureItems(root, skill); +} + +function qaAgentRunnerSourceFindings(item: ReturnType["items"][number]) { + if (!item.checks.includes("qa_agent_runner_source") || !item.exists) return []; + const root = dirname(item.absolute_path); + const required = [ + "main.py", + "components/agent_runner/default.yaml", + "components/agent_runner/default.py", + "assets/icon.svg", + ]; + const missing = required + .filter((path) => !existsSync(join(root, path))) + .map((path) => ({ + severity: "fail", + kind: "fixture_check_missing_file", + id: item.id, + path: `${item.path.replace(/\/[^/]+$/, "")}/${path}`, + })); + if (missing.length > 0) return missing; + + const manifest = readFileSync(item.absolute_path, "utf8"); + const runnerYaml = readFileSync(join(root, "components/agent_runner/default.yaml"), "utf8"); + const runnerPy = readFileSync(join(root, "components/agent_runner/default.py"), "utf8"); + const requiredText = [ + [manifest, "AgentRunner", "manifest.yaml"], + [manifest, "QAAgentRunnerPlugin", "manifest.yaml"], + [runnerYaml, "kind: AgentRunner", "components/agent_runner/default.yaml"], + [runnerYaml, "DefaultAgentRunner", "components/agent_runner/default.yaml"], + [runnerPy, "QA_AGENT_RUNNER_OK", "components/agent_runner/default.py"], + [runnerPy, "QA_AGENT_RUNNER_CONTROLLED_FAILURE", "components/agent_runner/default.py"], + ]; + return requiredText + .filter(([text, needle]) => !text.includes(needle)) + .map(([, needle, relativePath]) => ({ + severity: "fail", + kind: "fixture_check_missing_text", + id: item.id, + path: `${item.path.replace(/\/[^/]+$/, "")}/${relativePath}`, + detail: `missing ${needle}`, + })); +} + +function zipPackageFindings(item: ReturnType["items"][number]) { + if (!item.checks.includes("zip_package") || !item.exists) return []; + const header = readFileSync(item.absolute_path).subarray(0, 4).toString("binary"); + if (header === "PK\u0003\u0004" || header === "PK\u0005\u0006") return []; + return [{ + severity: "fail", + kind: "fixture_check_invalid_zip", + id: item.id, + path: item.path, + }]; +} + +export function commandFixtureList(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const skill = positional[0]; + const result = fixtureRows(ctx.root, skill); + + if (options.json === true) { + console.log(JSON.stringify(result.items, null, 2)); + return result.errors.length > 0 ? 1 : 0; + } + + for (const item of result.items) { + console.log([ + item.skill, + item.id, + item.kind, + item.exists ? "present" : "missing", + item.path, + item.title, + ].join("\t")); + } + for (const error of result.errors) console.error(`ERROR: ${error}`); + return result.errors.length > 0 ? 1 : 0; +} + +export function commandFixtureCheck(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const skill = positional[0]; + const result = fixtureRows(ctx.root, skill); + const findings = [ + ...result.errors.map((error) => ({ severity: "fail", kind: "invalid_manifest", detail: error })), + ...result.items + .filter((item) => !item.exists) + .map((item) => ({ + severity: "fail", + kind: "missing_fixture", + id: item.id, + path: item.path, + absolute_path: item.absolute_path, + })), + ...result.items.flatMap(qaAgentRunnerSourceFindings), + ...result.items.flatMap(zipPackageFindings), + ]; + const report = { + status: findings.some((finding) => finding.severity === "fail") ? "fail" : "pass", + fixture_count: result.items.length, + findings, + fixtures: result.items, + }; + + if (options.json === true) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(`# Fixture Check`); + console.log(""); + console.log(`status: ${report.status}`); + console.log(`fixture_count: ${report.fixture_count}`); + console.log(""); + console.log("## Fixtures"); + for (const item of result.items) { + console.log(`- ${item.id}: ${item.exists ? "present" : "missing"} (${item.path})`); + } + console.log(""); + console.log("## Findings"); + if (findings.length === 0) console.log("- None."); + else for (const finding of findings) console.log(`- [${finding.severity}] ${finding.kind}: ${"detail" in finding ? finding.detail : finding.id}`); + } + + return report.status === "pass" ? 0 : 1; +} diff --git a/skills/src/commands/log.ts b/skills/src/commands/log.ts new file mode 100644 index 0000000..aad9eaa --- /dev/null +++ b/skills/src/commands/log.ts @@ -0,0 +1,427 @@ +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; +import { dirname, join, resolve } from "node:path"; +import type { CommandContext } from "../types.ts"; +import { optionString, parseOptions, usage } from "../cli.ts"; +import { findStructuredItem, loadEnv } from "../fs.ts"; +import { + latestLangBotLogPath, + logPatternContextFromStructuredItem, + renderLogFinding, + renderLogSuccessSignal, + scanLogSources, + scanLogText, + strictLogGuardExitCode, + type LogFinding, + type LogGuardPatternContext, + type LogGuardResult, + type LogSuccessSignal, +} from "../log-guard.ts"; + +type LogGuardSession = { + source: "log-guard-session"; + run_id: string; + started_at: string; + started_at_local: string; + backend_log: string; + case_id: string; + case_skill: string; +}; + +type WatchSummary = { + mode: "watch"; + status: string; + path: string; + started_at_local: string; + finished_at_local: string; + bytes_read: number; + findings: LogFinding[]; + success_signals: LogSuccessSignal[]; +}; + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +function pad3(value: number): string { + return String(value).padStart(3, "0"); +} + +function localIsoWithOffset(date: Date): string { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absoluteOffset = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`, + `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`, + `${sign}${pad2(Math.floor(absoluteOffset / 60))}:${pad2(absoluteOffset % 60)}`, + ].join(""); +} + +function timestampSlug(localIso: string): string { + return localIso + .replace(/T/, "-") + .replace(/[.:+]/g, "-") + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function writeOrPrint(content: string, output: string | undefined): void { + if (!output) { + console.log(content.trimEnd()); + return; + } + const path = resolve(output); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + console.log(path); +} + +function positiveIntegerOption(options: Record, key: string, fallback: number): number { + const raw = optionString(options, key); + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!/^\d+$/.test(raw) || parsed <= 0) return fallback; + return parsed; +} + +function splitPatternList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(/\s*\|\s*|\s*,\s*/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function patternContextFromOptions(root: string, options: Record): LogGuardPatternContext { + const caseId = optionString(options, "case"); + const base = caseId ? logPatternContextFromStructuredItem(findStructuredItem(root, "cases", caseId)) : {}; + return { + successPatterns: [ + ...(base.successPatterns ?? []), + ...splitPatternList(optionString(options, "success-pattern")), + ], + failurePatterns: [ + ...(base.failurePatterns ?? []), + ...splitPatternList(optionString(options, "failure-pattern")), + ], + expectedFailures: [ + ...(base.expectedFailures ?? []), + ...splitPatternList(optionString(options, "expected-failure")), + ], + relatedTroubleshootingIds: base.relatedTroubleshootingIds ?? [], + }; +} + +function latestOrExplicitBackendLog(root: string, options: Record): string { + const explicit = optionString(options, "backend-log"); + if (explicit) return resolve(explicit); + const auto = latestLangBotLogPath(loadEnv(root)); + return auto ? resolve(auto) : ""; +} + +function renderSources(result: LogGuardResult): string[] { + const lines: string[] = []; + if (result.sources.length === 0) { + lines.push("- sources: no log files provided; use --backend-log or configure LANGBOT_REPO."); + return lines; + } + lines.push("- sources:"); + for (const source of result.sources) { + const origin = source.auto_detected ? ", auto" : ""; + const total = source.total_line_count === undefined ? "" : `/${source.total_line_count}`; + const range = source.start_line === undefined || source.end_line === undefined + ? "" + : `, lines ${source.start_line}-${source.end_line}`; + const timestamped = source.timestamped_line_count === undefined ? "" : `, ${source.timestamped_line_count} timestamped`; + lines.push(` - ${source.source}: ${source.path} (${source.status}${origin}, ${source.line_count}${total} lines${range}${timestamped})`); + } + return lines; +} + +function renderLogGuardMarkdown(title: string, result: LogGuardResult, extra: string[] = []): string { + const lines: string[] = []; + lines.push(`# ${title}`); + lines.push(""); + lines.push(`Generated: ${new Date().toISOString()}`); + lines.push(`Status: ${result.status}`); + lines.push(`Scan mode: ${result.scan.mode}`); + if (result.scan.since) lines.push(`Since: ${result.scan.since}`); + if (result.scan.until) lines.push(`Until: ${result.scan.until}`); + if (result.scan.tail_lines !== undefined) lines.push(`Tail lines: ${result.scan.tail_lines}`); + if (extra.length > 0) { + lines.push(""); + lines.push("## Context"); + for (const item of extra) lines.push(`- ${item}`); + } + lines.push(""); + lines.push("## Sources"); + lines.push(...renderSources(result)); + if (result.scan.warnings.length > 0) { + lines.push(""); + lines.push("## Scan Warnings"); + for (const warning of result.scan.warnings) lines.push(`- ${warning}`); + } + lines.push(""); + lines.push("## Findings"); + if (result.findings.length === 0) lines.push("- None."); + else for (const finding of result.findings) lines.push(renderLogFinding(finding)); + lines.push(""); + lines.push("## Success Signals"); + if (result.success_signals.length === 0) lines.push("- None."); + else for (const signal of result.success_signals) lines.push(renderLogSuccessSignal(signal)); + lines.push(""); + return `${lines.join("\n").trimEnd()}\n`; +} + +function statusFromEvents(findings: LogFinding[], successSignals: LogSuccessSignal[]): string { + if (findings.some((finding) => finding.severity === "fail" || finding.severity === "missing_input")) return "fail"; + if (findings.some((finding) => finding.severity === "matched_troubleshooting" && finding.related_to_case !== false)) return "fail"; + if (findings.some((finding) => finding.severity === "env_issue")) return "env_issue"; + if (findings.some((finding) => finding.severity === "warning")) return "warning"; + if (successSignals.length > 0) return "pass"; + return "no_activity"; +} + +function strictSummaryExitCode(status: string): number { + return status === "fail" || status === "env_issue" ? 1 : 0; +} + +function sessionDir(options: Record): string { + return optionString(options, "output-dir") ?? join("reports", "log-guards"); +} + +function sessionPath(options: Record, runId: string): string { + return join(sessionDir(options), `${runId}.json`); +} + +function readSession(options: Record): LogGuardSession | undefined { + const runId = optionString(options, "run-id"); + const explicitSession = optionString(options, "session"); + const path = explicitSession ? resolve(explicitSession) : runId ? resolve(sessionPath(options, runId)) : ""; + if (!path || !existsSync(path)) return undefined; + return JSON.parse(readFileSync(path, "utf8")) as LogGuardSession; +} + +export function commandLogScan(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + if (positional.length > 0) usage(); + + const result = scanLogSources(ctx.root, options, patternContextFromOptions(ctx.root, options)); + const output = optionString(options, "output"); + const content = options.json === true + ? `${JSON.stringify(result, null, 2)}\n` + : renderLogGuardMarkdown("Log Guard Scan", result, [ + optionString(options, "case") ? `case: ${optionString(options, "case")}` : "case: none", + options.strict === true ? "strict: yes" : "strict: no", + ]); + writeOrPrint(content, output); + return options.strict === true ? strictLogGuardExitCode(result) : 0; +} + +export async function commandLogWatch(ctx: CommandContext): Promise { + const { positional, options } = parseOptions(ctx.args.slice(2)); + if (positional.length > 0) usage(); + + const path = latestOrExplicitBackendLog(ctx.root, options); + if (!path) { + console.error("ERROR: no backend log found; pass --backend-log or configure LANGBOT_REPO."); + return 1; + } + if (!existsSync(path)) { + console.error(`ERROR: backend log does not exist: ${path}`); + return 1; + } + + const context = patternContextFromOptions(ctx.root, options); + const intervalMs = positiveIntegerOption(options, "interval-ms", 1000); + const durationMs = optionString(options, "duration-ms") + ? positiveIntegerOption(options, "duration-ms", 0) + : 0; + const startedAtLocal = localIsoWithOffset(new Date()); + const findings: LogFinding[] = []; + const successSignals: LogSuccessSignal[] = []; + let bytesRead = 0; + let offset = options["from-start"] === true ? 0 : statSync(path).size; + let baseLineNumber = options["from-start"] === true + ? 0 + : readFileSync(path).subarray(0, offset).toString("utf8").split(/\r?\n/).length - 1; + let carry = ""; + + if (options.json !== true) { + console.log(`# Log Guard Watch`); + console.log(`Path: ${path}`); + console.log(`Started: ${startedAtLocal}`); + console.log(`Mode: ${options["from-start"] === true ? "from-start" : "new-lines"}`); + } + + const startedMs = Date.now(); + let stopRequested = false; + const stop = (): void => { + stopRequested = true; + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + + const poll = (): void => { + const buffer = readFileSync(path); + if (buffer.length < offset) { + offset = 0; + baseLineNumber = 0; + carry = ""; + } + if (buffer.length === offset) return; + + const chunk = buffer.subarray(offset).toString("utf8"); + offset = buffer.length; + bytesRead += Buffer.byteLength(chunk); + const text = `${carry}${chunk}`; + const hasCompleteLine = /\r?\n$/.test(text); + const lastNewline = Math.max(text.lastIndexOf("\n"), text.lastIndexOf("\r")); + if (!hasCompleteLine && lastNewline === -1) { + carry = text; + return; + } + + const complete = hasCompleteLine ? text : text.slice(0, lastNewline + 1); + carry = hasCompleteLine ? "" : text.slice(lastNewline + 1); + if (!complete) return; + + const result = scanLogText(ctx.root, "backend", path, complete, {}, context, baseLineNumber, false); + baseLineNumber += complete.split(/\r?\n/).length - 1; + findings.push(...result.findings); + successSignals.push(...result.success_signals); + + if (options.json !== true) { + for (const finding of result.findings) console.log(renderLogFinding(finding)); + for (const signal of result.success_signals) console.log(renderLogSuccessSignal(signal)); + } + }; + + try { + do { + poll(); + if (stopRequested) break; + if (durationMs > 0 && Date.now() - startedMs >= durationMs) break; + await delay(Math.min(intervalMs, durationMs > 0 ? Math.max(1, durationMs - (Date.now() - startedMs)) : intervalMs)); + } while (!stopRequested); + + if (carry) { + const result = scanLogText(ctx.root, "backend", path, carry, {}, context, baseLineNumber, false); + findings.push(...result.findings); + successSignals.push(...result.success_signals); + if (options.json !== true) { + for (const finding of result.findings) console.log(renderLogFinding(finding)); + for (const signal of result.success_signals) console.log(renderLogSuccessSignal(signal)); + } + } + } finally { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + } + + const summary: WatchSummary = { + mode: "watch", + status: statusFromEvents(findings, successSignals), + path, + started_at_local: startedAtLocal, + finished_at_local: localIsoWithOffset(new Date()), + bytes_read: bytesRead, + findings, + success_signals: successSignals, + }; + + if (options.json === true) { + console.log(JSON.stringify(summary, null, 2)); + } else { + console.log(`Status: ${summary.status}`); + console.log(`Bytes read: ${summary.bytes_read}`); + } + return options.strict === true ? strictSummaryExitCode(summary.status) : 0; +} + +export function commandLogGuard(ctx: CommandContext): number { + const sub = ctx.args[2]; + if (sub === "start") return commandLogGuardStart(ctx); + if (sub === "stop") return commandLogGuardStop(ctx); + usage(); +} + +function commandLogGuardStart(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(3)); + if (positional.length > 0) usage(); + + const now = new Date(); + const startedAtLocal = localIsoWithOffset(now); + const runId = optionString(options, "run-id") ?? `log-guard-${timestampSlug(startedAtLocal)}`; + const caseId = optionString(options, "case") ?? ""; + const caseItem = caseId ? findStructuredItem(ctx.root, "cases", caseId) : undefined; + const session: LogGuardSession = { + source: "log-guard-session", + run_id: runId, + started_at: now.toISOString(), + started_at_local: startedAtLocal, + backend_log: latestOrExplicitBackendLog(ctx.root, options), + case_id: caseId, + case_skill: caseItem?.skill ?? "", + }; + const path = resolve(sessionPath(options, runId)); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, "utf8"); + + const result = { + ...session, + path, + stop_command: `bin/lbs log guard stop --run-id ${runId} --output-dir ${sessionDir(options)}`, + }; + if (options.json === true) console.log(JSON.stringify(result, null, 2)); + else { + console.log(`# Log Guard Session`); + console.log(`Run: ${runId}`); + console.log(`Started: ${startedAtLocal}`); + console.log(`Session: ${path}`); + if (session.backend_log) console.log(`Backend log: ${session.backend_log}`); + if (session.case_id) console.log(`Case: ${session.case_id}`); + console.log(`Stop: ${result.stop_command}`); + } + return 0; +} + +function commandLogGuardStop(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(3)); + if (positional.length > 0) usage(); + + const session = readSession(options); + if (!session) { + console.error("ERROR: log guard session not found; pass --run-id with --output-dir or --session."); + return 1; + } + + const now = new Date(); + const scanOptions: Record = { + ...options, + since: optionString(options, "since") ?? session.started_at_local, + until: optionString(options, "until") ?? localIsoWithOffset(now), + }; + if (session.backend_log && typeof scanOptions["backend-log"] !== "string") { + scanOptions["backend-log"] = session.backend_log; + } + + const caseId = optionString(options, "case") ?? session.case_id; + const context = caseId + ? logPatternContextFromStructuredItem(findStructuredItem(ctx.root, "cases", caseId)) + : patternContextFromOptions(ctx.root, options); + const result = scanLogSources(ctx.root, scanOptions, context); + const output = optionString(options, "output") ?? join(sessionDir(options), `${session.run_id}.md`); + const content = options.json === true + ? `${JSON.stringify({ session, result }, null, 2)}\n` + : renderLogGuardMarkdown("Log Guard Report", result, [ + `run_id: ${session.run_id}`, + `started: ${session.started_at_local}`, + `finished: ${scanOptions.until}`, + caseId ? `case: ${caseId}` : "case: none", + ]); + writeOrPrint(content, options.json === true ? optionString(options, "output") : output); + return options["no-strict"] === true ? 0 : strictLogGuardExitCode(result); +} diff --git a/skills/src/commands/skill.ts b/skills/src/commands/skill.ts new file mode 100644 index 0000000..7efbc0d --- /dev/null +++ b/skills/src/commands/skill.ts @@ -0,0 +1,128 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { CommandContext } from "../types.ts"; +import { fail, optionString, parseOptions, usage } from "../cli.ts"; +import { loadFixtureItems } from "../fixtures.ts"; +import { boolValue, getSkill, globMarkdownRefs, listValue, loadSkills, loadStructuredItems, scalar, skillsRoot } from "../fs.ts"; + +export function commandList(ctx: CommandContext): number { + for (const skill of loadSkills(ctx.root)) { + console.log(`${skill.directory}\t${skill.name}\t${skill.description}`); + } + return 0; +} + +function buildIndexData(root: string): Record { + const caseSummary = (item: ReturnType[number]) => ({ + id: scalar(item.fields, "id"), + title: scalar(item.fields, "title"), + mode: scalar(item.fields, "mode"), + area: scalar(item.fields, "area"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + risk: scalar(item.fields, "risk"), + ci_eligible: boolValue(item.fields, "ci_eligible") ?? false, + tags: listValue(item.fields, "tags"), + automation: scalar(item.fields, "automation"), + setup_automation: listValue(item.fields, "setup_automation"), + setup_provides_env: listValue(item.fields, "setup_provides_env"), + evidence_required: listValue(item.fields, "evidence_required"), + }); + const troubleshootingSummary = (item: ReturnType[number]) => ({ + id: scalar(item.fields, "id"), + title: scalar(item.fields, "title"), + category: scalar(item.fields, "category") || "product", + related_cases: listValue(item.fields, "related_cases"), + }); + const suiteSummary = (item: ReturnType[number]) => ({ + id: scalar(item.fields, "id"), + title: scalar(item.fields, "title"), + description: scalar(item.fields, "description"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + tags: listValue(item.fields, "tags"), + cases: listValue(item.fields, "cases"), + }); + return { + generated_by: "lbs", + skills: loadSkills(root).map((skill) => ({ + directory: skill.directory, + name: skill.name, + description: skill.description, + references: globMarkdownRefs(skill.path), + cases: loadStructuredItems(root, "cases", skill.directory).map((item) => scalar(item.fields, "id")), + case_summaries: loadStructuredItems(root, "cases", skill.directory).map(caseSummary), + suites: loadStructuredItems(root, "suites", skill.directory).map((item) => scalar(item.fields, "id")), + suite_summaries: loadStructuredItems(root, "suites", skill.directory).map(suiteSummary), + fixtures: loadFixtureItems(root, skill.directory).items.map((item) => ({ + id: item.id, + title: item.title, + kind: item.kind, + path: item.path, + related_cases: item.related_cases, + })), + troubleshooting: loadStructuredItems(root, "troubleshooting", skill.directory).map((item) => scalar(item.fields, "id")), + troubleshooting_summaries: loadStructuredItems(root, "troubleshooting", skill.directory).map(troubleshootingSummary), + })), + }; +} + +export function commandIndex(ctx: CommandContext): number { + const { options } = parseOptions(ctx.args.slice(1)); + const data = buildIndexData(ctx.root); + const out = join(ctx.root, "skills.index.json"); + const content = `${JSON.stringify(data, null, 2)}\n`; + if (options.check === true) { + if (!existsSync(out)) { + console.error(`ERROR: missing index: ${out}`); + return 1; + } + if (readFileSync(out, "utf8") !== content) { + console.error(`ERROR: index is stale: ${out}`); + return 1; + } + console.log(`OK ${out}`); + return 0; + } + writeFileSync(out, content, "utf8"); + console.log(out); + return 0; +} + +export function commandNewSkill(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(1)); + const name = positional[0]; + if (!name) usage(); + + const skillDir = join(skillsRoot(ctx.root), name); + const skillMd = join(skillDir, "SKILL.md"); + if (existsSync(skillMd)) fail(`skill already exists: ${skillDir}`); + + mkdirSync(skillDir, { recursive: true }); + const description = optionString(options, "description") ?? `Use when working with ${name}.`; + const text = + `---\nname: ${name}\ndescription: ${description}\n---\n\n` + + `# ${name}\n\n` + + "Add concise routing and workflow instructions here.\n"; + writeFileSync(skillMd, text, "utf8"); + console.log(skillMd); + return 0; +} + +export function commandNewRef(ctx: CommandContext): number { + const skill = ctx.args[1]; + const rawName = ctx.args[2]; + if (!skill || !rawName) usage(); + + const skillDir = getSkill(ctx.root, skill).path; + const refsDir = join(skillDir, "references"); + mkdirSync(refsDir, { recursive: true }); + const name = rawName.endsWith(".md") ? rawName : `${rawName}.md`; + const refPath = join(refsDir, name); + if (existsSync(refPath)) fail(`reference already exists: ${refPath}`); + + const title = name.replace(/\.md$/, "").replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); + writeFileSync(refPath, `# ${title}\n\nAdd concise reusable instructions here.\n`, "utf8"); + console.log(refPath); + return 0; +} diff --git a/skills/src/commands/suite.ts b/skills/src/commands/suite.ts new file mode 100644 index 0000000..7ab556c --- /dev/null +++ b/skills/src/commands/suite.ts @@ -0,0 +1,745 @@ +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { execPath } from "node:process"; +import type { CommandContext, StructuredItem } from "../types.ts"; +import { fail, optionString, parseOptions, usage } from "../cli.ts"; +import { findStructuredItem, getSkill, listValue, loadStructuredItems, scalar, yamlList, yamlQuote } from "../fs.ts"; +import { caseAutomationReadiness, caseEnvReadiness, caseFixtureReadiness, caseManualReadiness, runtimeEnv } from "../readiness.ts"; +import { lbsScriptPath, setupAutomationEntries } from "../setup-automation.ts"; + +function suitePath(root: string, skillName: string, id: string): string { + const skill = getSkill(root, skillName); + const dir = join(skill.path, "suites"); + mkdirSync(dir, { recursive: true }); + return join(dir, `${id}.yaml`); +} + +function caseItemById(root: string, id: string): StructuredItem { + return findStructuredItem(root, "cases", id); +} + +function suiteCaseSummary(root: string, id: string): Record { + const item = caseItemById(root, id); + const env = runtimeEnv(root); + const caseId = scalar(item.fields, "id"); + return { + skill: item.skill, + id: caseId, + title: scalar(item.fields, "title"), + mode: scalar(item.fields, "mode"), + area: scalar(item.fields, "area"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + risk: scalar(item.fields, "risk"), + tags: listValue(item.fields, "tags"), + preconditions: listValue(item.fields, "preconditions"), + setup: listValue(item.fields, "setup"), + setup_automation: setupAutomationEntries(item), + setup_provides_env: listValue(item.fields, "setup_provides_env"), + automation: scalar(item.fields, "automation"), + evidence_required: listValue(item.fields, "evidence_required"), + env_readiness: caseEnvReadiness(item, env), + automation_readiness: caseAutomationReadiness(item, env), + fixture_readiness: caseFixtureReadiness(root, caseId), + manual_readiness: caseManualReadiness(item), + }; +} + +function suiteSummary(item: StructuredItem): Record { + return { + skill: item.skill, + id: scalar(item.fields, "id"), + title: scalar(item.fields, "title"), + description: scalar(item.fields, "description"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + tags: listValue(item.fields, "tags"), + cases: listValue(item.fields, "cases"), + }; +} + +function findSuite(root: string, args: string[]): StructuredItem { + if (args.length < 1 || args.length > 2) usage(); + return args.length === 1 + ? findStructuredItem(root, "suites", args[0]) + : findStructuredItem(root, "suites", args[0], args[1]); +} + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +function pad3(value: number): string { + return String(value).padStart(3, "0"); +} + +function localIsoWithOffset(date: Date): string { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absoluteOffset = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`, + `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`, + `${sign}${pad2(Math.floor(absoluteOffset / 60))}:${pad2(absoluteOffset % 60)}`, + ].join(""); +} + +function timestampSlug(localIso: string): string { + return localIso + .replace(/T/, "-") + .replace(/[.:+]/g, "-") + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function writeOrPrint(content: string, output: string | undefined): void { + if (!output) { + console.log(content.trimEnd()); + return; + } + const path = resolve(output); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + console.log(path); +} + +function suiteCases(root: string, item: StructuredItem): Record[] { + return listValue(item.fields, "cases").map((id) => suiteCaseSummary(root, id)); +} + +function statusOf(caseItem: Record, key: string): string { + const value = caseItem[key] as Record | undefined; + return typeof value?.status === "string" ? value.status : "not_required"; +} + +function readinessSummary(cases: Array>): Record { + const missingEnv = cases.filter((item) => statusOf(item, "env_readiness") === "missing").map((item) => item.id); + const missingAutomation = cases.filter((item) => statusOf(item, "automation_readiness") === "missing").map((item) => item.id); + const missingFixture = cases.filter((item) => statusOf(item, "fixture_readiness") === "missing").map((item) => item.id); + const manualCheck = cases.filter((item) => statusOf(item, "manual_readiness") === "manual_check").map((item) => item.id); + const missingCount = missingEnv.length + missingAutomation.length + missingFixture.length; + return { + status: missingCount > 0 ? "missing" : manualCheck.length > 0 ? "manual_check" : "ready", + missing_env_cases: missingEnv, + missing_automation_env_cases: missingAutomation, + missing_fixture_cases: missingFixture, + manual_check_cases: manualCheck, + }; +} + +function hasProbeCases(cases: Array>): boolean { + return cases.some((caseItem) => caseItem.mode === "probe"); +} + +function suiteReportGuidance(cases: Array>): string { + return hasProbeCases(cases) + ? "Run each case according to its mode; probe cases may collect non-UI evidence, while agent-browser cases still require browser/UI execution." + : "Run each case through browser/UI first; use test report with the evidence directory and backend log window after execution."; +} + +function suiteResultPolicy(cases: Array>): string[] { + if (hasProbeCases(cases)) { + return [ + "A suite is not pass unless every case has a result and required evidence for the same run window.", + "agent-browser cases require UI/browser results; probe cases are judged by their declared checks and required evidence.", + "blocked and env_issue are not product pass; report them separately.", + ]; + } + + return [ + "A suite is not pass unless every case has a UI/browser result and required evidence for the same run window.", + "blocked and env_issue are not product pass; report them separately.", + ]; +} + +function suiteEvidencePolicy(cases: Array>): string[] { + if (hasProbeCases(cases)) { + return [ + "Run each case according to its mode. Agent-browser cases use browser/UI; probe cases use their declared probe steps or automation.", + "Use each case evidence_dir for screenshots, console.log, network.log, automation-result.json, result.json, and any probe artifacts.", + "After case execution and report review, run each result_command_template with the final status and collected evidence.", + "After per-case result.json files exist, run the suite report command to aggregate them.", + "blocked and env_issue are not product pass; they must be reported separately from pass.", + ]; + } + + return [ + "Run each case through browser/UI. API/curl/log diagnostics cannot make a UI case pass by themselves.", + "Use each case evidence_dir for screenshots, console.log, network.log, automation-result.json, and final result.json.", + "After case execution and report review, run each result_command_template with the final status and collected evidence.", + "After per-case result.json files exist, run the suite report command to aggregate them.", + "blocked and env_issue are not product pass; they must be reported separately from pass.", + ]; +} + +function buildSuitePlan(root: string, item: StructuredItem): Record { + const suite = suiteSummary(item); + const cases = suiteCases(root, item); + return { + ...suite, + cases, + readiness: readinessSummary(cases), + commands: cases.map((caseItem) => ({ + id: caseItem.id, + plan: `bin/lbs test plan ${caseItem.id}`, + start: `bin/lbs test start ${caseItem.id}`, + automation: caseItem.automation ? `bin/lbs test run ${caseItem.id} --dry-run` : "", + })), + report_guidance: suiteReportGuidance(cases), + }; +} + +export function commandSuiteNew(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const id = positional[0]; + const title = optionString(options, "title"); + if (!id || !title) usage(); + + const skill = optionString(options, "skill") ?? "langbot-testing"; + const path = suitePath(ctx.root, skill, id); + if (existsSync(path)) fail(`suite already exists: ${path}`); + + const text = + `id: ${id}\n` + + `title: ${yamlQuote(title)}\n` + + `description: ${yamlQuote(optionString(options, "description") ?? "Describe when to run this suite.")}\n` + + `type: ${optionString(options, "type") ?? "smoke"}\n` + + `priority: ${optionString(options, "priority") ?? "p2"}\n` + + "tags:\n" + + yamlList([optionString(options, "type") ?? "smoke"]) + + "\ncases:\n" + + yamlList(["webui-login-state"]) + + "\n"; + + writeFileSync(path, text, "utf8"); + console.log(path); + return 0; +} + +export function commandSuiteList(ctx: CommandContext): number { + const { positional, options } = parseOptions(ctx.args.slice(2)); + const skill = positional[0]; + const rows = loadStructuredItems(ctx.root, "suites", skill) + .map(suiteSummary) + .filter((row) => !optionString(options, "type") || row.type === optionString(options, "type")) + .filter((row) => !optionString(options, "priority") || row.priority === optionString(options, "priority")); + + if (options.json === true) { + console.log(JSON.stringify(rows, null, 2)); + return 0; + } + + for (const row of rows) { + console.log([ + row.skill, + row.id, + row.type, + row.priority, + Array.isArray(row.cases) ? row.cases.length : 0, + row.title, + ].join("\t")); + } + return 0; +} + +export function commandSuiteShow(ctx: CommandContext): number { + const item = findSuite(ctx.root, ctx.args.slice(2)); + console.log(item.raw.trimEnd()); + return 0; +} + +export function commandSuitePlan(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findSuite(ctx.root, args); + const plan = buildSuitePlan(ctx.root, item); + const suite = suiteSummary(item); + const cases = suiteCases(ctx.root, item); + + if (options.json === true) { + console.log(JSON.stringify(plan, null, 2)); + return 0; + } + + console.log(`# Suite Plan: ${suite.id}`); + console.log(""); + console.log(`Title: ${suite.title}`); + console.log(`Type: ${suite.type}`); + console.log(`Priority: ${suite.priority}`); + console.log(`Description: ${suite.description}`); + console.log(""); + const readiness = readinessSummary(cases); + console.log("## Readiness"); + console.log(`Status: ${readiness.status}`); + for (const [key, value] of Object.entries(readiness)) { + if (key === "status" || !Array.isArray(value) || value.length === 0) continue; + console.log(`- ${key}: ${value.join(", ")}`); + } + console.log(""); + console.log("## Cases"); + for (const [index, caseItem] of cases.entries()) { + console.log(`${index + 1}. ${caseItem.id} [${caseItem.priority}/${caseItem.risk}] ${caseItem.title}`); + console.log(` - plan: bin/lbs test plan ${caseItem.id}`); + console.log(` - start: bin/lbs test start ${caseItem.id}`); + if (caseItem.automation) console.log(` - automation dry-run: bin/lbs test run ${caseItem.id} --dry-run`); + console.log(` - evidence: ${Array.isArray(caseItem.evidence_required) ? caseItem.evidence_required.join(", ") : ""}`); + const envReadiness = caseItem.env_readiness as Record; + const automationReadiness = caseItem.automation_readiness as Record; + const fixtureReadiness = caseItem.fixture_readiness as Record; + const manualReadiness = caseItem.manual_readiness as Record; + const missing: string[] = []; + if (Array.isArray(envReadiness.missing) && envReadiness.missing.length > 0) missing.push(`env=${envReadiness.missing.join(",")}`); + if (Array.isArray(automationReadiness.missing) && automationReadiness.missing.length > 0) missing.push(`automation_env=${automationReadiness.missing.join(",")}`); + if (Array.isArray(fixtureReadiness.missing) && fixtureReadiness.missing.length > 0) missing.push(`fixture=${fixtureReadiness.missing.join(",")}`); + const manualLabel = manualReadiness.status === "manual_check" ? " manual_check" : ""; + console.log(` - readiness: ${missing.length === 0 ? `ready${manualLabel}` : `missing ${missing.join(" ")}`}`); + const preconditions = caseItem.preconditions; + if (Array.isArray(preconditions) && preconditions.length > 0) console.log(` - preconditions: ${preconditions.length}`); + const setupAutomation = caseItem.setup_automation; + if (Array.isArray(setupAutomation) && setupAutomation.length > 0) console.log(` - setup automation: ${setupAutomation.length}`); + } + console.log(""); + console.log("## Result Policy"); + for (const policy of suiteResultPolicy(cases)) console.log(`- ${policy}`); + return 0; +} + +function suiteStartPath(root: string, path: string): string { + return resolve(root, path); +} + +function ensureDirectory(root: string, path: string, label: string): void { + const resolvedPath = suiteStartPath(root, path); + if (existsSync(resolvedPath) && !statSync(resolvedPath).isDirectory()) { + fail(`${label} exists and is not a directory: ${resolvedPath}`); + } + mkdirSync(resolvedPath, { recursive: true }); +} + +function buildSuiteStart( + root: string, + item: StructuredItem, + args: string[], + options: Record, +): Record { + const now = new Date(); + const startedAtLocal = localIsoWithOffset(now); + const suite = suiteSummary(item); + const suiteId = String(suite.id); + const runId = optionString(options, "run-id") ?? `${timestampSlug(startedAtLocal)}-${suiteId}`; + const evidenceRoot = optionString(options, "evidence-dir") ?? join("reports", "evidence", runId); + const reportPath = join("reports", `${runId}.md`); + const manifestPath = join(evidenceRoot, "suite-start.json"); + const handoffPath = join(evidenceRoot, "suite-start.md"); + const cases = suiteCases(root, item).map((caseItem) => { + const caseId = String(caseItem.id); + const caseRunId = `${runId}-${caseId}`; + const evidenceDir = join(evidenceRoot, caseId); + const consoleLog = join(evidenceDir, "console.log"); + const caseReportPath = join("reports", `${caseRunId}.md`); + return { + ...caseItem, + run_id: caseRunId, + evidence_dir: evidenceDir, + plan_command: `bin/lbs test plan ${caseId}`, + start_command: `bin/lbs test start ${caseId}`, + automation_command: caseItem.automation + ? `bin/lbs test run ${caseId} --run-id ${caseRunId} --output ${evidenceDir}` + : "", + report_command: caseItem.automation + ? `bin/lbs test report ${caseId} --since "${startedAtLocal}" --console-log ${consoleLog} --evidence-dir ${evidenceDir} --output ${caseReportPath}` + : `bin/lbs test report ${caseId} --since "${startedAtLocal}" --evidence-dir ${evidenceDir} --output ${caseReportPath}`, + result_command_template: `bin/lbs test result ${caseId} --result --reason "" --evidence-dir ${evidenceDir} --run-id ${caseRunId} --started-at "${startedAtLocal}" --evidence ${Array.isArray(caseItem.evidence_required) ? caseItem.evidence_required.join(",") : ""}`, + }; + }); + + const locator = args.join(" "); + return { + run_id: runId, + started_at: now.toISOString(), + started_at_local: startedAtLocal, + suite, + evidence_root: evidenceRoot, + manifest_path: manifestPath, + handoff_path: handoffPath, + cases, + suite_report_path: reportPath, + plan_command: `bin/lbs suite plan ${locator}`, + report_command: `bin/lbs suite report ${locator} --run-id ${runId} --evidence-dir ${evidenceRoot} --output ${reportPath}`, + evidence_policy: suiteEvidencePolicy(cases), + }; +} + +function writeSuiteStartArtifacts(root: string, start: Record, rendered: string): void { + const evidenceRoot = String(start.evidence_root || ""); + if (!evidenceRoot) return; + + ensureDirectory(root, evidenceRoot, "suite evidence directory"); + for (const caseItem of start.cases as Array>) { + const evidenceDir = String(caseItem.evidence_dir || ""); + if (evidenceDir) ensureDirectory(root, evidenceDir, "case evidence directory"); + } + + const manifestPath = String(start.manifest_path || ""); + if (manifestPath) { + const path = suiteStartPath(root, manifestPath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(start, null, 2)}\n`, "utf8"); + } + + const handoffPath = String(start.handoff_path || ""); + if (handoffPath) { + const path = suiteStartPath(root, handoffPath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, rendered, "utf8"); + } +} + +function renderSuiteStart(start: Record): string { + const suite = start.suite as Record; + const cases = start.cases as Array>; + const lines: string[] = []; + lines.push(`# Suite Start: ${suite.id}`); + lines.push(""); + lines.push(`Run: ${start.run_id}`); + lines.push(`Started: ${start.started_at_local}`); + lines.push(`Title: ${suite.title}`); + lines.push(`Evidence root: ${start.evidence_root}`); + lines.push(""); + lines.push("## Commands"); + lines.push(`- plan: ${start.plan_command}`); + lines.push(`- report: ${start.report_command}`); + lines.push(""); + lines.push("## Cases"); + for (const [index, caseItem] of cases.entries()) { + lines.push(`${index + 1}. ${caseItem.id} [${caseItem.priority}/${caseItem.risk}] ${caseItem.title}`); + lines.push(` - evidence_dir: ${caseItem.evidence_dir}`); + lines.push(` - plan: ${caseItem.plan_command}`); + if (caseItem.automation_command) lines.push(` - automation: ${caseItem.automation_command}`); + else lines.push(` - manual start: ${caseItem.start_command}`); + lines.push(` - report: ${caseItem.report_command}`); + lines.push(` - result template: ${caseItem.result_command_template}`); + } + lines.push(""); + lines.push("## Evidence Policy"); + for (const item of start.evidence_policy as string[]) lines.push(`- ${item}`); + return `${lines.join("\n").trimEnd()}\n`; +} + +export function commandSuiteStart(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findSuite(ctx.root, args); + const start = buildSuiteStart(ctx.root, item, args, options); + const rendered = renderSuiteStart(start); + writeSuiteStartArtifacts(ctx.root, start, rendered); + const content = options.json === true ? `${JSON.stringify(start, null, 2)}\n` : rendered; + writeOrPrint(content, optionString(options, "output")); + return 0; +} + +function suiteRunCaseArgs(root: string, caseItem: Record, headed: boolean): string[] { + const args = [ + lbsScriptPath(), + "--root", + root, + "test", + "run", + String(caseItem.id), + "--run-id", + String(caseItem.run_id), + "--output", + String(caseItem.evidence_dir), + ]; + if (headed) args.push("--headed"); + return args; +} + +function suiteReportExitCode(status: string): number { + if (status === "pass") return 0; + if (status === "blocked" || status === "env_issue" || status === "flaky") return 2; + return 1; +} + +function outputTail(value: string | Buffer | null | undefined): string { + return String(value ?? "").trim().slice(-4000); +} + +function exitStatusFromResultStatus(status: string): number { + if (status === "pass") return 0; + if (status === "blocked" || status === "env_issue" || status === "flaky") return 2; + return 1; +} + +function executionStatusFromExitStatus(status: number): string { + if (status === 0) return "ok"; + if (status === 2) return "classified"; + return "nonzero"; +} + +function executionFromCaseResultFile(caseItem: Record): Record | null { + const resultPath = join(String(caseItem.evidence_dir), "result.json"); + if (!existsSync(resultPath)) return null; + try { + const parsed = JSON.parse(readFileSync(resultPath, "utf8")) as Record; + if ( + parsed.case_id !== caseItem.id || + parsed.run_id !== caseItem.run_id || + typeof parsed.status !== "string" + ) return null; + const exitStatus = exitStatusFromResultStatus(parsed.status); + return { + status: executionStatusFromExitStatus(exitStatus), + exit_status: exitStatus, + reason: typeof parsed.reason === "string" ? parsed.reason : "result.json completed", + result_status: parsed.status, + result_json: resultPath, + }; + } catch { + return null; + } +} + +function executionProblemStatus(executions: Array>): string { + const statuses = executions.map((item) => String(item.status)); + if (statuses.includes("nonzero")) return "fail"; + if (statuses.includes("skipped")) return "incomplete"; + return ""; +} + +function missingReadinessReason(caseItem: Record): string { + const labels: Array<[string, string]> = [ + ["env", "env_readiness"], + ["automation_env", "automation_readiness"], + ["fixture", "fixture_readiness"], + ]; + const missing = labels.flatMap(([label, key]) => { + const value = caseItem[key] as Record | undefined; + if (value?.status !== "missing") return []; + const names = Array.isArray(value.missing) ? value.missing.filter((item): item is string => typeof item === "string") : []; + return [`${label}=${names.length > 0 ? names.join(",") : "missing"}`]; + }); + return missing.length > 0 + ? `case readiness missing (${missing.join(" ")}); rerun with --include-not-ready after fixing or intentionally accepting readiness gaps` + : ""; +} + +export function commandSuiteRun(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findSuite(ctx.root, args); + const start = buildSuiteStart(ctx.root, item, args, options); + const renderedStart = renderSuiteStart(start); + const dryRun = options["dry-run"] === true; + if (!dryRun) writeSuiteStartArtifacts(ctx.root, start, renderedStart); + + const executions = []; + for (const caseItem of start.cases as Array>) { + if (statusOf(caseItem, "manual_readiness") === "manual_check" && options["include-manual-check"] !== true) { + executions.push({ id: caseItem.id, status: "skipped", reason: "case requires manual_check; rerun with --include-manual-check after confirming preconditions" }); + continue; + } + const missingReadiness = missingReadinessReason(caseItem); + if (missingReadiness && options["include-not-ready"] !== true) { + executions.push({ id: caseItem.id, status: "skipped", reason: missingReadiness }); + continue; + } + if (!caseItem.automation) { + executions.push({ id: caseItem.id, status: "skipped", reason: "case has no automation" }); + continue; + } + const runArgs = suiteRunCaseArgs(ctx.root, caseItem, options.headed === true); + if (dryRun) { + executions.push({ id: caseItem.id, status: "planned", reason: "dry-run; case automation not executed", command: [execPath, ...runArgs].join(" ") }); + continue; + } + if (options.json !== true) console.log(`Suite case: ${caseItem.id}`); + const result = spawnSync(execPath, runArgs, { + cwd: ctx.root, + encoding: "utf8", + stdio: options.json === true ? "pipe" : "inherit", + }); + const fileExecution = result.error ? executionFromCaseResultFile(caseItem) : null; + const status = typeof fileExecution?.exit_status === "number" + ? fileExecution.exit_status + : result.error ? 1 : result.status ?? 1; + executions.push({ + id: caseItem.id, + status: fileExecution?.status ?? executionStatusFromExitStatus(status), + exit_status: status, + reason: fileExecution?.reason ?? result.error?.message ?? "", + result_status: fileExecution?.result_status, + result_json: fileExecution?.result_json, + spawn_error: fileExecution && result.error ? result.error.message : undefined, + stdout: outputTail(result.stdout), + stderr: outputTail(result.stderr), + }); + } + + const report = buildSuiteReport(ctx.root, item, { + ...options, + "run-id": String(start.run_id), + "evidence-dir": String(start.evidence_root), + }, executions); + const payload = { + run_id: start.run_id, + evidence_root: start.evidence_root, + executions, + report, + }; + const content = options.json === true + ? `${JSON.stringify(payload, null, 2)}\n` + : renderSuiteReport(report); + writeOrPrint(content, optionString(options, "output") ?? (options.json === true || dryRun ? undefined : String(start.suite_report_path || ""))); + return dryRun ? 0 : suiteReportExitCode(String(report.status)); +} + +function arrayField(data: Record, key: string): string[] { + const value = data[key]; + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function readCaseResult(evidenceDir: string, caseId: string, expectedRunId: string, requiredEvidence: string[]): Record { + const resultPath = join(evidenceDir, "result.json"); + if (!existsSync(resultPath)) { + return { status: "missing", path: resultPath, reason: "result.json not found" }; + } + try { + const parsed = JSON.parse(readFileSync(resultPath, "utf8")) as Record; + if (parsed.case_id !== caseId) { + return { + status: "invalid", + path: resultPath, + reason: `result.json case_id mismatch: expected ${caseId}, got ${String(parsed.case_id ?? "missing")}`, + }; + } + if (expectedRunId && parsed.run_id !== expectedRunId) { + return { + status: "invalid", + path: resultPath, + reason: `result.json run_id mismatch: expected ${expectedRunId}, got ${String(parsed.run_id ?? "missing")}`, + }; + } + const collected = arrayField(parsed, "evidence_collected"); + const missing = requiredEvidence.filter((item) => !collected.includes(item)); + return { + status: typeof parsed.status === "string" ? parsed.status : "invalid", + path: resultPath, + reason: typeof parsed.reason === "string" ? parsed.reason : "", + started_at_local: typeof parsed.started_at_local === "string" ? parsed.started_at_local : "", + finished_at_local: typeof parsed.finished_at_local === "string" ? parsed.finished_at_local : "", + url: typeof parsed.url === "string" ? parsed.url : "", + evidence_collected: collected, + evidence_required: requiredEvidence, + evidence_missing: missing, + evidence_status: missing.length === 0 ? "complete" : "incomplete", + }; + } catch (error) { + return { status: "invalid", path: resultPath, reason: String(error) }; + } +} + +function suiteStatus(caseResults: Array>): string { + const statuses = caseResults.map((item) => String(item.status)); + if (statuses.length === 0) return "not_run"; + if (statuses.includes("fail") || statuses.includes("invalid")) return "fail"; + if (statuses.includes("missing")) return "incomplete"; + if (caseResults.some((item) => item.status === "pass" && item.evidence_status !== "complete")) return "incomplete"; + if (statuses.every((status) => status === "pass")) return "pass"; + if (statuses.includes("blocked")) return "blocked"; + if (statuses.includes("env_issue")) return "env_issue"; + if (statuses.includes("flaky")) return "flaky"; + return "unknown"; +} + +function buildSuiteReport( + root: string, + item: StructuredItem, + options: Record, + executions: Array> = [], +): Record { + const suite = suiteSummary(item); + const runId = optionString(options, "run-id") ?? ""; + const evidenceRoot = optionString(options, "evidence-dir") ?? (runId ? join("reports", "evidence", runId) : ""); + const cases = suiteCases(root, item).map((caseItem) => { + const caseId = String(caseItem.id); + const expectedCaseRunId = runId ? `${runId}-${caseId}` : ""; + const evidenceDir = evidenceRoot ? join(evidenceRoot, caseId) : ""; + const requiredEvidence = Array.isArray(caseItem.evidence_required) ? caseItem.evidence_required : []; + const result = evidenceDir + ? readCaseResult(evidenceDir, caseId, expectedCaseRunId, requiredEvidence) + : { status: "missing", path: "", reason: "Set --evidence-dir or --run-id to locate case result.json files" }; + return { + ...caseItem, + evidence_dir: evidenceDir, + result, + }; + }); + const counts: Record = {}; + for (const item of cases) { + const status = String((item.result as Record).status); + counts[status] = (counts[status] ?? 0) + 1; + } + + const resultStatus = suiteStatus(cases.map((item) => item.result as Record)); + const executionStatus = executionProblemStatus(executions); + return { + generated_at: new Date().toISOString(), + run_id: runId, + suite, + evidence_root: evidenceRoot, + status: executionStatus || resultStatus, + counts, + cases, + execution_status: executionStatus || "ok", + decision_policy: [ + "pass requires every case result to be pass.", + "suite run pass also requires every attempted execution to finish ok.", + "blocked and env_issue are not product pass.", + "pass results missing required evidence keep the suite incomplete.", + "result.json must match the expected case_id and suite case run_id.", + "missing or invalid result.json means the suite is incomplete or failed to collect evidence.", + ], + }; +} + +function renderSuiteReport(report: Record): string { + const suite = report.suite as Record; + const cases = report.cases as Array>; + const counts = report.counts as Record; + const lines: string[] = []; + lines.push(`# Suite Report: ${suite.id}`); + lines.push(""); + lines.push(`Generated: ${report.generated_at}`); + if (report.run_id) lines.push(`Run: ${report.run_id}`); + lines.push(`Title: ${suite.title}`); + lines.push(`Status: ${report.status}`); + lines.push(`Evidence root: ${report.evidence_root || "not provided"}`); + lines.push(""); + lines.push("## Counts"); + for (const key of Object.keys(counts).sort()) lines.push(`- ${key}: ${counts[key]}`); + if (Object.keys(counts).length === 0) lines.push("- None."); + lines.push(""); + lines.push("## Cases"); + for (const caseItem of cases) { + const result = caseItem.result as Record; + lines.push(`- ${caseItem.id}: ${result.status} - ${result.reason || "no reason"}`); + if (Array.isArray(result.evidence_missing) && result.evidence_missing.length > 0) { + lines.push(` evidence_missing: ${result.evidence_missing.join(", ")}`); + } + if (caseItem.evidence_dir) lines.push(` evidence_dir: ${caseItem.evidence_dir}`); + if (result.path) lines.push(` result_json: ${result.path}`); + } + lines.push(""); + lines.push("## Decision Policy"); + for (const item of report.decision_policy as string[]) lines.push(`- ${item}`); + return `${lines.join("\n").trimEnd()}\n`; +} + +export function commandSuiteReport(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findSuite(ctx.root, args); + const report = buildSuiteReport(ctx.root, item, options); + const content = options.json === true ? `${JSON.stringify(report, null, 2)}\n` : renderSuiteReport(report); + writeOrPrint(content, optionString(options, "output")); + return 0; +} diff --git a/skills/src/commands/test.ts b/skills/src/commands/test.ts new file mode 100644 index 0000000..67ddc31 --- /dev/null +++ b/skills/src/commands/test.ts @@ -0,0 +1,1505 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { env as processEnv, execPath } from "node:process"; +import type { CommandContext, StructuredItem } from "../types.ts"; +import { parseOptions, usage } from "../cli.ts"; +import { caseEvidenceValues, testResultStatusValues } from "../constants.ts"; +import { boolValue, findStructuredItem, listValue, loadEnv, loadStructuredItems, scalar } from "../fs.ts"; +import { splitEnvAnyGroup } from "../env-groups.ts"; +import { + readAutomationResultEvidence, + renderLogFinding, + renderLogSuccessSignal, + scanStructuredLogSources, + type AutomationResultEvidence, + type LogFinding, + type LogGuardResult, + type LogSuccessSignal, +} from "../log-guard.ts"; +import { + automationEnvDefaults, + caseAutomationReadiness, + caseEnvReadiness, + caseFixtureReadiness, + caseManualReadiness, + redactEnvValue, + resolvedAutomationEnvOverrides, + runtimeEnv, + type AutomationReadiness, + type EnvReadiness, + type FixtureReadiness, + type ManualReadiness, +} from "../readiness.ts"; +import { + lbsScriptPath, + parseSetupAutomationEntry, + setupAutomationEntries, + setupAutomationEvidenceName, + setupAutomationScriptPath, +} from "../setup-automation.ts"; + +type TroubleshootingSummary = { + id: string; + title: string; + patterns: string[]; + verification: string; +}; + +type TestPlan = { + id: string; + title: string; + mode: string; + principle: string; + env: Record; + env_readiness: EnvReadiness; + automation_readiness: AutomationReadiness; + fixture_readiness: FixtureReadiness; + manual_readiness: ManualReadiness; + required_skills: string[]; + preconditions: string[]; + setup: string[]; + setup_automation: string[]; + setup_provides_env: string[]; + cleanup: string[]; + steps: string[]; + checks: string[]; + diagnostics: string[]; + visual_checks: string[]; + evidence_required: string[]; + success_patterns: string[]; + failure_patterns: string[]; + troubleshooting: TroubleshootingSummary[]; + report_template: Record; +}; + +type TestStart = { + run_id: string; + started_at: string; + started_at_local: string; + case: Record; + environment: Record; + required_skills: string[]; + preconditions: string[]; + setup: string[]; + setup_automation: string[]; + setup_provides_env: string[]; + cleanup: string[]; + steps: string[]; + checks: string[]; + success_patterns: string[]; + failure_patterns: string[]; + evidence_required: string[]; + automation?: { + script: string; + command: string; + evidence_dir: string; + }; + recommended_report_path: string; + plan_command: string; + report_command: string; + result_command_template: string; + evidence_checklist: string[]; +}; + +type TestAutomationRun = { + run_id: string; + started_at: string; + started_at_local: string; + case: Record; + setup_automation: SetupAutomation[]; + automation: { + script: string; + script_path: string; + exists: boolean; + required_env: string[]; + evidence_dir: string; + console_log: string; + network_log: string; + screenshot: string; + automation_result_json: string; + result_json: string; + command: string; + report_command: string; + env_defaults: Record; + env_aliases: Array<{ + target: string; + source: string; + configured: boolean; + }>; + pipeline_env_required: boolean; + }; +}; + +type SetupAutomation = { + entry: string; + kind: "case" | "node"; + target: string; + args: string[]; + command: string; + dry_run_command: string; + evidence_dir: string; + exists: boolean; +}; + +type TestResultRecord = { + source: "final"; + case_id: string; + run_id: string; + written_at: string; + written_at_local: string; + started_at: string; + started_at_local: string; + finished_at: string; + finished_at_local: string; + status: string; + reason: string; + url: string; + browser_path: string; + evidence_dir: string; + evidence_collected: string[]; + evidence_required: string[]; + evidence_missing: string[]; + evidence_status: "complete" | "incomplete"; + report_path: string; + notes: string; +}; + +type ManualEvidenceTemplate = { + result: string; + [key: string]: string; +}; + +type TestReport = { + generated_at: string; + case: Record; + result_options: string[]; + automation_result: AutomationResultEvidence; + manual_evidence: ManualEvidenceTemplate; + environment: Record; + required_skills: string[]; + steps: string[]; + checks: string[]; + diagnostics: string[]; + evidence_required: string[]; + success_patterns: string[]; + failure_patterns: string[]; + expected_failures: string[]; + troubleshooting: TroubleshootingSummary[]; + log_guard: LogGuardResult; +}; + +type TestRecommendation = { + id: string; + reason: string; +}; + +type TestRecommendReport = { + generated_at: string; + changed_files: string[]; + recommendations: TestRecommendation[]; + commands: string[]; + notes: string[]; +}; + +function relatedTroubleshooting(root: string, item: StructuredItem): StructuredItem[] { + return listValue(item.fields, "troubleshooting") + .map((id) => { + try { + return findStructuredItem(root, "troubleshooting", id); + } catch { + return null; + } + }) + .filter((entry): entry is StructuredItem => entry !== null); +} + +function findCase(root: string, args: string[]): StructuredItem { + if (args.length < 1 || args.length > 2) usage(); + + return args.length === 1 + ? findStructuredItem(root, "cases", args[0]) + : findStructuredItem(root, "cases", args[0], args[1]); +} + +function caseSummary(item: StructuredItem): Record { + return { + skill: item.skill, + id: scalar(item.fields, "id"), + title: scalar(item.fields, "title"), + mode: scalar(item.fields, "mode"), + area: scalar(item.fields, "area"), + type: scalar(item.fields, "type"), + priority: scalar(item.fields, "priority"), + risk: scalar(item.fields, "risk"), + ci_eligible: boolValue(item.fields, "ci_eligible") ?? false, + tags: listValue(item.fields, "tags"), + }; +} + +function caseMode(item: StructuredItem): string { + return scalar(item.fields, "mode") || "agent-browser"; +} + +function isProbeMode(mode: string): boolean { + return mode === "probe"; +} + +function modePrinciple(mode: string): string { + return isProbeMode(mode) + ? "Run the declared probe steps and collect the required evidence. Browser/UI interaction is not required unless the case steps explicitly call for it." + : "Use browser/UI interaction as the primary QA path. API/curl/log checks are diagnostic only and cannot make a UI case pass by themselves."; +} + +function stepHeading(mode: string): string { + return isProbeMode(mode) ? "Probe Steps" : "Browser Steps"; +} + +function visualChecks(mode: string): string[] { + if (isProbeMode(mode)) return []; + return [ + "If the active agent has screenshot/vision capability, capture before/after screenshots.", + "Look for blank pages, overlapping text, hidden primary actions, error toasts, or broken layout.", + "If no visual model is available, use DOM/accessibility snapshots and console output instead.", + ]; +} + +function reportTemplate(mode: string): Record { + if (isProbeMode(mode)) { + return { + result: "pass | fail | blocked | env_issue | flaky", + target_tested: "Probe target, endpoint, file, command, or service actually checked", + execution_path: "automation script | shell command | direct API | other", + probe_result: "What the probe observed", + metrics_or_artifacts: "Metrics, logs, filesystem artifacts, traces, or profiles collected", + diagnostics: "Extra diagnostics used, if any", + matched_troubleshooting: "Troubleshooting ids matched, if any", + assets_to_update: "New case/reference/troubleshooting entries to add", + }; + } + + return { + result: "pass | fail | blocked | env_issue | flaky", + url_tested: "LANGBOT_FRONTEND_URL actually opened", + browser_path: "Computer Use | Playwright MCP | other", + ui_result: "What the user-visible UI showed", + console_errors: "Unexpected browser console errors, if any", + backend_logs: "Relevant backend log lines, if checked", + screenshots: "Screenshot paths or skipped reason", + diagnostics: "API/curl/log diagnostics used, if any", + matched_troubleshooting: "Troubleshooting ids matched, if any", + assets_to_update: "New case/reference/troubleshooting entries to add", + }; +} + +function evidenceChecklist(mode: string): string[] { + if (isProbeMode(mode)) { + return [ + "Execute the declared probe steps or automation script.", + "Store required logs, API diagnostics, filesystem artifacts, or other evidence in the evidence directory.", + "After execution, run the report command to scan logs from the start timestamp.", + "Write a final result.json with the result command only after required evidence has been collected.", + "Mark the final result as pass, fail, blocked, env_issue, or flaky in the generated report.", + ]; + } + + return [ + "Open the configured LangBot WebUI and execute the browser steps.", + "Capture screenshot paths when screenshot/vision tooling is available.", + "Record unexpected console errors and failed network requests without pasting secrets.", + "After browser execution, run the report command to scan logs from the start timestamp.", + "Write a final result.json with the result command only after required evidence has been collected.", + "Mark the final result as pass, fail, blocked, env_issue, or flaky in the generated report.", + ]; +} + +function manualEvidenceTemplate(mode: string): ManualEvidenceTemplate { + if (isProbeMode(mode)) { + return { + result: "pass | fail | blocked | env_issue | flaky", + target_tested: "TODO: probe target, endpoint, file, command, or service actually checked", + execution_path: "TODO: automation script | shell command | direct API | other", + probe_result: "TODO: observed probe result", + metrics_or_artifacts: "TODO: metrics, logs, filesystem artifacts, traces, or profiles collected", + diagnostics: "TODO: additional diagnostics used, if any", + matched_troubleshooting: "TODO: troubleshooting ids matched, if any", + assets_to_update: "TODO: case/reference/troubleshooting updates to make", + }; + } + + return { + result: "pass | fail | blocked | env_issue | flaky", + url_tested: "LANGBOT_FRONTEND_URL actually opened", + browser_path: "Computer Use | Playwright MCP | direct Playwright | other", + ui_result: "TODO: user-visible result", + console_errors: "TODO: unexpected browser console errors or none", + network_symptoms: "TODO: failed requests, websocket issues, or none", + backend_logs: "TODO: relevant backend log lines or skipped reason", + frontend_logs: "TODO: relevant frontend dev-server log lines or skipped reason", + screenshots: "TODO: screenshot paths or skipped reason", + diagnostics: "TODO: API/curl/log diagnostics used, if any", + matched_troubleshooting: "TODO: troubleshooting ids matched, if any", + assets_to_update: "TODO: case/reference/troubleshooting updates to make", + }; +} + +function envSummary(item: StructuredItem, env: Record): Record { + const keys = [ + ...listValue(item.fields, "env"), + ...listValue(item.fields, "env_any").flatMap(splitEnvAnyGroup), + ]; + return Object.fromEntries(Array.from(new Set(keys)).map((key) => [key, redactEnvValue(key, env[key] ?? "")])); +} + +function buildPlan(root: string, item: StructuredItem): TestPlan { + const env = runtimeEnv(root); + const troubles = relatedTroubleshooting(root, item); + const id = scalar(item.fields, "id"); + const mode = caseMode(item); + return { + id, + title: scalar(item.fields, "title"), + mode, + principle: modePrinciple(mode), + env: envSummary(item, env), + env_readiness: caseEnvReadiness(item, env), + automation_readiness: caseAutomationReadiness(item, env), + fixture_readiness: caseFixtureReadiness(root, id), + manual_readiness: caseManualReadiness(item), + required_skills: listValue(item.fields, "skills"), + preconditions: listValue(item.fields, "preconditions"), + setup: listValue(item.fields, "setup"), + setup_automation: setupAutomationEntries(item), + setup_provides_env: listValue(item.fields, "setup_provides_env"), + cleanup: listValue(item.fields, "cleanup"), + steps: listValue(item.fields, "steps"), + checks: listValue(item.fields, "checks"), + diagnostics: listValue(item.fields, "diagnostics"), + visual_checks: visualChecks(mode), + evidence_required: listValue(item.fields, "evidence_required"), + success_patterns: listValue(item.fields, "success_patterns"), + failure_patterns: listValue(item.fields, "failure_patterns"), + troubleshooting: troubles.map((entry) => ({ + id: scalar(entry.fields, "id"), + title: scalar(entry.fields, "title"), + patterns: listValue(entry.fields, "patterns"), + verification: scalar(entry.fields, "verification"), + })), + report_template: reportTemplate(mode), + }; +} + +export function commandTestPlan(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findCase(ctx.root, args); + const plan = buildPlan(ctx.root, item); + + if (options.json === true) { + console.log(JSON.stringify(plan, null, 2)); + return 0; + } + + console.log(`# Test Plan: ${plan.id}`); + console.log(""); + console.log(`Title: ${plan.title}`); + console.log(`Mode: ${plan.mode}`); + console.log(""); + console.log("## Principle"); + console.log(plan.principle); + console.log(""); + console.log("## Environment"); + for (const [key, value] of Object.entries(plan.env)) console.log(`- ${key}=${value}`); + if (plan.env_readiness.missing.length > 0) console.log(`- missing: ${plan.env_readiness.missing.join(", ")}`); + console.log(""); + console.log("## Automation Readiness"); + console.log(`- status: ${plan.automation_readiness.status}`); + if (plan.automation_readiness.script) console.log(`- script: ${plan.automation_readiness.script}`); + if (plan.automation_readiness.pipeline_env_required) console.log("- pipeline env: case-specific required"); + if (plan.automation_readiness.missing.length > 0) console.log(`- missing: ${plan.automation_readiness.missing.join(", ")}`); + if (plan.automation_readiness.defaulted.length > 0) console.log(`- case defaults: ${plan.automation_readiness.defaulted.join(", ")}`); + for (const alias of plan.automation_readiness.env_aliases) { + console.log(`- alias: ${alias.target} <- ${alias.source} (${alias.configured ? "configured" : "missing"})`); + } + console.log(""); + console.log("## Fixture Readiness"); + console.log(`- status: ${plan.fixture_readiness.status}`); + for (const fixture of plan.fixture_readiness.required) { + console.log(`- ${fixture.id}: ${fixture.exists ? "present" : "missing"} (${fixture.path})`); + } + console.log(""); + console.log("## Manual Readiness"); + console.log(`- status: ${plan.manual_readiness.status}`); + if (plan.preconditions.length === 0) console.log("- preconditions: none declared"); + for (const precondition of plan.preconditions) console.log(`- precondition: ${precondition}`); + if (plan.setup.length > 0) for (const item of plan.setup) console.log(`- setup: ${item}`); + if (plan.setup_automation.length > 0) { + for (const item of plan.setup_automation) console.log(`- setup automation: ${item}`); + } + if (plan.setup_provides_env.length > 0) console.log(`- setup provides env: ${plan.setup_provides_env.join(", ")}`); + if (plan.cleanup.length > 0) for (const item of plan.cleanup) console.log(`- cleanup: ${item}`); + console.log(""); + console.log("## Required Skills"); + for (const skill of plan.required_skills) console.log(`- ${skill}`); + console.log(""); + console.log(`## ${stepHeading(plan.mode)}`); + for (const [index, step] of plan.steps.entries()) console.log(`${index + 1}. ${step}`); + console.log(""); + console.log("## Checks"); + for (const check of plan.checks) console.log(`- ${check}`); + console.log(""); + console.log("## Diagnostics"); + if (plan.diagnostics.length === 0) console.log("- Optional: use API/curl/logs only to diagnose failures."); + for (const diagnostic of plan.diagnostics) console.log(`- ${diagnostic}`); + console.log(""); + if (plan.visual_checks.length > 0) { + console.log("## Visual Checks"); + for (const check of plan.visual_checks) console.log(`- ${check}`); + console.log(""); + } + console.log("## Required Evidence"); + if (plan.evidence_required.length === 0) console.log("- None declared."); + for (const evidence of plan.evidence_required) console.log(`- ${evidence}`); + console.log(""); + console.log("## Success Signals"); + if (plan.success_patterns.length === 0) console.log("- None declared."); + for (const pattern of plan.success_patterns) console.log(`- ${pattern}`); + console.log(""); + console.log("## Failure Signals"); + if (plan.failure_patterns.length === 0) console.log("- None declared."); + for (const pattern of plan.failure_patterns) console.log(`- ${pattern}`); + console.log(""); + console.log("## Troubleshooting"); + for (const entry of plan.troubleshooting) { + console.log(`- ${entry.id}: ${entry.title}`); + for (const pattern of entry.patterns) console.log(` pattern: ${pattern}`); + } + console.log(""); + console.log("## Report Template"); + for (const [key, value] of Object.entries(plan.report_template)) console.log(`- ${key}: ${value}`); + return 0; +} + +function normalizeChangedPath(path: string): string { + return path.replace(/\\/g, "/").replace(/^\.\//, ""); +} + +function isChangedFilePath(path: string): boolean { + return Boolean(path) && !path.endsWith("/") && !path.startsWith("--- ") && !path.startsWith("+++ "); +} + +function existingCaseIds(root: string): Set { + return new Set(loadStructuredItems(root, "cases").map((item) => scalar(item.fields, "id"))); +} + +function addRecommendation( + output: TestRecommendation[], + existing: Set, + id: string, + reason: string, +): void { + if (!existing.has(id) || output.some((item) => item.id === id)) return; + output.push({ id, reason }); +} + +function changedFilesFromGit(repo: string, prefix: string): string[] { + if (!existsSync(repo)) return []; + const argsList = [ + ["diff", "--name-only", "HEAD"], + ["status", "--short"], + ]; + const files: string[] = []; + for (const args of argsList) { + const result = spawnSync("git", args, { + cwd: repo, + encoding: "utf8", + }); + if (result.status !== 0) continue; + for (const raw of result.stdout.split(/\r?\n/)) { + if (!raw.trim()) continue; + const file = args[0] === "status" + ? raw.slice(3).trim().split(/\s+->\s+/).pop() ?? "" + : raw.trim(); + if (isChangedFilePath(file)) files.push(`${prefix}/${normalizeChangedPath(file)}`); + } + } + return files; +} + +function repoCandidates(root: string, env: Record): Array<{ path: string; prefix: string }> { + return [ + { path: env.LANGBOT_REPO || resolve(root, "../LangBot"), prefix: "LangBot" }, + { path: env.LANGBOT_PLUGIN_SDK_REPO || resolve(root, "../langbot-plugin-sdk"), prefix: "langbot-plugin-sdk" }, + { path: env.LANGBOT_AGENT_RUNNER_REPO || resolve(root, "../langbot-agent-runner"), prefix: "langbot-agent-runner" }, + { path: env.LANGBOT_LOCAL_AGENT_REPO || resolve(root, "../langbot-local-agent"), prefix: "langbot-local-agent" }, + ]; +} + +function repeatedOptionValues(args: string[], key: string): string[] { + const values: string[] = []; + for (let i = 0; i < args.length; i += 1) { + if (args[i] !== `--${key}`) continue; + const value = args[i + 1]; + if (value && !value.startsWith("--")) values.push(value); + } + return values; +} + +function changedFiles(root: string, explicitFiles: string[]): string[] { + const explicit = explicitFiles.map(normalizeChangedPath); + if (explicit.length > 0) return Array.from(new Set(explicit)); + + const env = runtimeEnv(root); + const files = repoCandidates(root, env).flatMap((repo) => changedFilesFromGit(repo.path, repo.prefix)); + return Array.from(new Set(files)).sort(); +} + +function buildRecommendations(root: string, files: string[]): TestRecommendation[] { + const existing = existingCaseIds(root); + const recommendations: TestRecommendation[] = []; + const text = files.map(normalizeChangedPath); + const has = (pattern: RegExp) => text.some((file) => pattern.test(file)); + + if (has(/(^|\/)(result_normalizer|orchestrator|descriptor|errors)\.py$/) || has(/agent_runner\/result\.py$/)) { + addRecommendation(recommendations, existing, "agent-runner-fixture-contract", "Deterministic AgentRunner fixture contract should still execute."); + addRecommendation(recommendations, existing, "agent-runner-behavior-matrix", "AgentRunner result/orchestration contract changed."); + } + if (has(/fixtures\/plugins\/qa-agent-runner|components\/agent_runner|manifest\.ya?ml$/)) { + addRecommendation(recommendations, existing, "agent-runner-fixture-contract", "AgentRunner fixture or runner manifest changed."); + addRecommendation(recommendations, existing, "agent-runner-live-install", "AgentRunner plugin package should still install and register."); + addRecommendation(recommendations, existing, "agent-runner-qa-debug-chat", "Installed QA AgentRunner should still execute through Debug Chat."); + } + if (has(/fixtures\/plugins\/qa-plugin-smoke|qa_plugin_|qa-plugin-smoke/i)) { + addRecommendation(recommendations, existing, "qa-plugin-smoke-live-install", "QA plugin smoke fixture should install and expose tools."); + } + if (has(/(run_ledger|agent_run\.py|run_ledger\.py|alembic.*agent_run|test_run_ledger)/i)) { + addRecommendation(recommendations, existing, "agent-runner-ledger-invariants", "Run ledger schema/status code changed."); + addRecommendation(recommendations, existing, "agent-runner-ledger-stress", "Run ledger queue/claim behavior changed."); + addRecommendation(recommendations, existing, "agent-runner-ledger-contention", "Run ledger claim behavior changed; check local write contention."); + addRecommendation(recommendations, existing, "agent-runner-async-db-readiness", "Async DB readiness gates ledger concurrency probes."); + addRecommendation(recommendations, existing, "agent-runner-ledger-concurrency", "Run ledger concurrency/auth tests are relevant."); + } + if (has(/(plugin\/handler|agent_run_api|history_event_api|state_api|pull_api|runtime\/plugin|test_mgr_agent_runner|test_pull_api_handlers)/)) { + addRecommendation(recommendations, existing, "agent-runner-runtime-chaos", "SDK/runtime or Host action handling changed."); + } + if (has(/(LangBot\/web\/|^web\/|control-plane|frontend|\/page|\/pages)/)) { + addRecommendation(recommendations, existing, "agent-runner-release-preflight", "UI/control-plane surface changed; preflight catches wrong live target."); + addRecommendation(recommendations, existing, "webui-login-state", "Browser session must still reach LangBot WebUI."); + } + if (has(/(local-agent|context|compaction|rag|tool|mcp|multimodal)/i)) { + addRecommendation(recommendations, existing, "qa-plugin-smoke-live-install", "Tool-loop checks depend on the QA plugin smoke fixture."); + addRecommendation(recommendations, existing, "local-agent-basic-debug-chat", "Local-agent user path may be affected."); + addRecommendation(recommendations, existing, "local-agent-plugin-tool-call-debug-chat", "Tool-loop changes need browser evidence."); + } + if (has(/(^|\/)(acp|claude|codex)(\/|-)|langbot-agent-runner\//i)) { + addRecommendation(recommendations, existing, "acp-agent-runner-debug-chat", "External AgentRunner path may be affected."); + } + if (recommendations.length === 0) { + addRecommendation(recommendations, existing, "agent-runner-release-preflight", "No narrow AgentRunner rule matched; start with preflight if this branch touches runner behavior."); + } + return recommendations; +} + +function buildRecommendReport(root: string, explicitFiles: string[]): TestRecommendReport { + const files = changedFiles(root, explicitFiles); + const recommendations = buildRecommendations(root, files); + return { + generated_at: new Date().toISOString(), + changed_files: files, + recommendations, + commands: recommendations.flatMap((item) => [ + `bin/lbs test plan ${item.id}`, + `bin/lbs test run ${item.id} --dry-run`, + ]), + notes: [ + "Run probe cases before browser cases.", + "Remove --dry-run only after readiness and manual_check preconditions are confirmed.", + "Treat blocked/env_issue separately from product fail.", + "Browser cases still need required UI evidence before pass.", + ], + }; +} + +function renderRecommendReport(report: TestRecommendReport): string { + const lines: string[] = []; + lines.push("# Test Recommendations"); + lines.push(""); + lines.push(`Generated: ${report.generated_at}`); + lines.push(""); + lines.push("## Changed Files"); + if (report.changed_files.length === 0) lines.push("- None detected. Pass --file to recommend from explicit paths."); + else for (const file of report.changed_files) lines.push(`- ${file}`); + lines.push(""); + lines.push("## Recommended Cases"); + if (report.recommendations.length === 0) lines.push("- None."); + for (const item of report.recommendations) { + lines.push(`- ${item.id}: ${item.reason}`); + } + lines.push(""); + lines.push("## Commands"); + for (const command of report.commands) lines.push(`- ${command}`); + lines.push(""); + lines.push("## Notes"); + for (const note of report.notes) lines.push(`- ${note}`); + return `${lines.join("\n").trimEnd()}\n`; +} + +export function commandTestRecommend(ctx: CommandContext): number { + const { options } = parseOptions(ctx.args.slice(2)); + const report = buildRecommendReport(ctx.root, repeatedOptionValues(ctx.args.slice(2), "file")); + if (options.json === true) console.log(JSON.stringify(report, null, 2)); + else console.log(renderRecommendReport(report).trimEnd()); + return 0; +} + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +function pad3(value: number): string { + return String(value).padStart(3, "0"); +} + +function localIsoWithOffset(date: Date): string { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absoluteOffset = Math.abs(offsetMinutes); + return [ + `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`, + `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`, + `${sign}${pad2(Math.floor(absoluteOffset / 60))}:${pad2(absoluteOffset % 60)}`, + ].join(""); +} + +function timestampSlug(localIso: string): string { + return localIso + .replace(/T/, "-") + .replace(/[.:+]/g, "-") + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function caseLocator(args: string[]): string { + return args.join(" "); +} + +function automationScript(item: StructuredItem): string { + return scalar(item.fields, "automation"); +} + +function setupCaseExists(root: string, target: string): boolean { + return loadStructuredItems(root, "cases").some((item) => scalar(item.fields, "id") === target); +} + +function setupAutomation(root: string, item: StructuredItem, runId: string, evidenceRoot: string): SetupAutomation[] { + return setupAutomationEntries(item).map((entry, index) => { + const spec = parseSetupAutomationEntry(entry); + const evidenceDir = join("setup", setupAutomationEvidenceName(index, spec)); + const fullEvidenceDir = join(evidenceRoot, evidenceDir); + const command = spec.kind === "case" + ? `bin/lbs test run ${spec.target} --run-id ${runId}-${spec.target} --output ${fullEvidenceDir}` + : `node ${spec.target}${spec.args.length > 0 ? ` ${spec.args.join(" ")}` : ""}`; + const dryRunCommand = spec.kind === "case" ? `${command} --dry-run` : ""; + return { + entry, + kind: spec.kind, + target: spec.target, + args: spec.args, + command, + dry_run_command: dryRunCommand, + evidence_dir: fullEvidenceDir, + exists: spec.kind === "case" ? setupCaseExists(root, spec.target) : existsSync(setupAutomationScriptPath(root, spec)), + }; + }); +} + +function isoFromDateInput(input: string): string { + const parsed = Date.parse(input); + return Number.isNaN(parsed) ? "" : new Date(parsed).toISOString(); +} + +function commaList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function buildTestResult( + root: string, + item: StructuredItem, + options: Record, +): { result?: TestResultRecord; errors: string[] } { + const errors: string[] = []; + const status = typeof options.result === "string" ? options.result : ""; + const reason = typeof options.reason === "string" ? options.reason : ""; + const evidenceDir = typeof options["evidence-dir"] === "string" ? options["evidence-dir"] : ""; + const now = new Date(); + const writtenAtLocal = localIsoWithOffset(now); + const startedAtLocal = typeof options["started-at"] === "string" ? options["started-at"] : writtenAtLocal; + const finishedAtLocal = typeof options["finished-at"] === "string" ? options["finished-at"] : writtenAtLocal; + const startedAt = isoFromDateInput(startedAtLocal); + const finishedAt = isoFromDateInput(finishedAtLocal); + const evidenceCollected = commaList(typeof options.evidence === "string" ? options.evidence : undefined); + const evidenceRequired = listValue(item.fields, "evidence_required"); + const evidenceMissing = evidenceRequired.filter((value) => !evidenceCollected.includes(value)); + + if (!status) errors.push("--result is required"); + else if (!testResultStatusValues.includes(status)) { + errors.push(`--result must be one of ${testResultStatusValues.join(", ")}`); + } + if (!reason) errors.push("--reason is required"); + if (!evidenceDir) errors.push("--evidence-dir is required"); + if (!startedAt) errors.push(`--started-at is not a valid date/time: ${startedAtLocal}`); + if (!finishedAt) errors.push(`--finished-at is not a valid date/time: ${finishedAtLocal}`); + + const allowedEvidence = new Set(caseEvidenceValues); + for (const value of evidenceCollected) { + if (!allowedEvidence.has(value)) errors.push(`--evidence contains unsupported value '${value}'`); + } + if (status === "pass" && evidenceMissing.length > 0) { + errors.push(`pass result is missing required evidence: ${evidenceMissing.join(", ")}`); + } + + if (errors.length > 0) return { errors }; + + const resolvedEvidenceDir = resolve(evidenceDir); + return { + errors, + result: { + source: "final", + case_id: scalar(item.fields, "id"), + run_id: typeof options["run-id"] === "string" ? options["run-id"] : resolvedEvidenceDir.split(/[\\/]/).pop() ?? "", + written_at: now.toISOString(), + written_at_local: writtenAtLocal, + started_at: startedAt, + started_at_local: startedAtLocal, + finished_at: finishedAt, + finished_at_local: finishedAtLocal, + status, + reason, + url: typeof options.url === "string" ? options.url : "", + browser_path: typeof options["browser-path"] === "string" ? options["browser-path"] : "", + evidence_dir: evidenceDir, + evidence_collected: evidenceCollected, + evidence_required: evidenceRequired, + evidence_missing: evidenceMissing, + evidence_status: evidenceMissing.length === 0 ? "complete" : "incomplete", + report_path: typeof options.report === "string" ? options.report : "", + notes: typeof options.notes === "string" ? options.notes : "", + }, + }; +} + +function renderTestResult(result: TestResultRecord): string { + const lines: string[] = []; + lines.push(`# Test Result: ${result.case_id}`); + lines.push(""); + lines.push(`Run: ${result.run_id}`); + lines.push(`Status: ${result.status}`); + lines.push(`Reason: ${result.reason}`); + lines.push(`Evidence dir: ${result.evidence_dir}`); + lines.push(`Evidence status: ${result.evidence_status}`); + if (result.evidence_missing.length > 0) lines.push(`Evidence missing: ${result.evidence_missing.join(", ")}`); + if (result.url) lines.push(`URL: ${result.url}`); + if (result.browser_path) lines.push(`Browser path: ${result.browser_path}`); + if (result.report_path) lines.push(`Report: ${result.report_path}`); + lines.push(""); + lines.push("## Evidence Collected"); + if (result.evidence_collected.length === 0) lines.push("- None declared."); + else for (const value of result.evidence_collected) lines.push(`- ${value}`); + return `${lines.join("\n").trimEnd()}\n`; +} + +function buildStart(root: string, item: StructuredItem, args: string[]): TestStart { + const now = new Date(); + const startedAtLocal = localIsoWithOffset(now); + const id = scalar(item.fields, "id"); + const mode = caseMode(item); + const runId = `${timestampSlug(startedAtLocal)}-${id}`; + const recommendedReportPath = join("reports", `${runId}.md`); + const evidenceDir = join("reports", "evidence", runId); + const locator = caseLocator(args); + const script = automationScript(item); + const automationCommand = script + ? `bin/lbs test run ${locator} --run-id ${runId} --output ${evidenceDir}` + : undefined; + const consoleLog = join(evidenceDir, "console.log"); + const reportCommand = script + ? `bin/lbs test report ${locator} --since "${startedAtLocal}" --console-log ${consoleLog} --evidence-dir ${evidenceDir} --output ${recommendedReportPath}` + : `bin/lbs test report ${locator} --since "${startedAtLocal}" --evidence-dir ${evidenceDir} --output ${recommendedReportPath}`; + const resultCommandTemplate = `bin/lbs test result ${locator} --result --reason "" --evidence-dir ${evidenceDir} --started-at "${startedAtLocal}" --evidence ${listValue(item.fields, "evidence_required").join(",")}`; + + return { + run_id: runId, + started_at: now.toISOString(), + started_at_local: startedAtLocal, + case: caseSummary(item), + environment: envSummary(item, { ...loadEnv(root), ...processEnv }), + required_skills: listValue(item.fields, "skills"), + preconditions: listValue(item.fields, "preconditions"), + setup: listValue(item.fields, "setup"), + setup_automation: setupAutomationEntries(item), + setup_provides_env: listValue(item.fields, "setup_provides_env"), + cleanup: listValue(item.fields, "cleanup"), + steps: listValue(item.fields, "steps"), + checks: listValue(item.fields, "checks"), + evidence_required: listValue(item.fields, "evidence_required"), + success_patterns: listValue(item.fields, "success_patterns"), + failure_patterns: listValue(item.fields, "failure_patterns"), + automation: script + ? { + script, + command: automationCommand ?? "", + evidence_dir: evidenceDir, + } + : undefined, + recommended_report_path: recommendedReportPath, + plan_command: `bin/lbs test plan ${locator}`, + report_command: reportCommand, + result_command_template: resultCommandTemplate, + evidence_checklist: evidenceChecklist(mode), + }; +} + +function renderMarkdownStart(start: TestStart): string { + const lines: string[] = []; + const reportCase = start.case; + + lines.push(`# Test Start: ${reportCase.id}`); + lines.push(""); + lines.push(`Run: ${start.run_id}`); + lines.push(`Started: ${start.started_at_local}`); + lines.push(`Title: ${reportCase.title}`); + lines.push(`Skill: ${reportCase.skill}`); + lines.push(""); + lines.push("## Commands"); + lines.push(`- plan: ${start.plan_command}`); + if (start.automation) lines.push(`- automation: ${start.automation.command}`); + lines.push(`- report: ${start.report_command}`); + lines.push(`- result template: ${start.result_command_template}`); + lines.push(""); + lines.push("## Evidence Checklist"); + for (const item of start.evidence_checklist) lines.push(`- ${item}`); + lines.push(""); + lines.push(...renderLines("Required Evidence", start.evidence_required)); + lines.push(""); + lines.push(...renderLines("Preconditions", start.preconditions)); + lines.push(...renderLines("Setup", start.setup)); + lines.push(...renderLines("Setup Automation", start.setup_automation)); + lines.push(...renderLines("Setup Provides Env", start.setup_provides_env)); + lines.push(...renderLines("Cleanup", start.cleanup)); + lines.push(`## ${stepHeading(String(reportCase.mode || "agent-browser"))}`); + for (const [index, step] of start.steps.entries()) lines.push(`${index + 1}. ${step}`); + lines.push(""); + lines.push(...renderLines("Checks", start.checks)); + lines.push(...renderLines("Success Signals", start.success_patterns)); + lines.push(...renderLines("Failure Signals", start.failure_patterns)); + lines.push("## Environment"); + for (const [key, value] of Object.entries(start.environment)) lines.push(`- ${key}=${value}`); + lines.push(""); + + return `${lines.join("\n").trimEnd()}\n`; +} + +export function commandTestStart(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findCase(ctx.root, args); + const start = buildStart(ctx.root, item, args); + const output = typeof options.output === "string" ? options.output : undefined; + const content = options.json === true ? `${JSON.stringify(start, null, 2)}\n` : renderMarkdownStart(start); + + writeOrPrint(content, output); + return 0; +} + +export function commandTestResult(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findCase(ctx.root, args); + const { result, errors } = buildTestResult(ctx.root, item, options); + if (!result) { + for (const error of errors) console.error(`ERROR: ${error}`); + return 1; + } + + const resultPath = join(result.evidence_dir, "result.json"); + mkdirSync(dirname(resultPath), { recursive: true }); + writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + + if (options.json === true) console.log(JSON.stringify(result, null, 2)); + else console.log(renderTestResult(result).trimEnd()); + return 0; +} + +function buildAutomationRun( + root: string, + item: StructuredItem, + args: string[], + options: Record, +): TestAutomationRun { + const now = new Date(); + const startedAtLocal = localIsoWithOffset(now); + const id = scalar(item.fields, "id"); + const sourceEnv = runtimeEnv(root); + const runId = typeof options["run-id"] === "string" ? options["run-id"] : `${timestampSlug(startedAtLocal)}-${id}`; + const script = automationScript(item); + const scriptPath = script ? resolve(root, script) : ""; + const evidenceDir = typeof options.output === "string" + ? options.output + : join("reports", "evidence", runId); + const locator = caseLocator(args); + const consoleLog = join(evidenceDir, "console.log"); + const reportPath = join("reports", `${runId}.md`); + const reportCommand = `bin/lbs test report ${locator} --since "${startedAtLocal}" --console-log ${consoleLog} --evidence-dir ${evidenceDir} --output ${reportPath}`; + const runCommand = [ + "bin/lbs", + "test", + "run", + locator, + "--run-id", + runId, + "--output", + evidenceDir, + ].join(" "); + + return { + run_id: runId, + started_at: now.toISOString(), + started_at_local: startedAtLocal, + case: caseSummary(item), + setup_automation: setupAutomation(root, item, runId, evidenceDir), + automation: { + script, + script_path: scriptPath, + exists: scriptPath ? existsSync(scriptPath) : false, + required_env: [...listValue(item.fields, "automation_env"), ...listValue(item.fields, "automation_env_any")], + evidence_dir: evidenceDir, + console_log: consoleLog, + network_log: join(evidenceDir, "network.log"), + screenshot: join(evidenceDir, "screenshot.png"), + automation_result_json: join(evidenceDir, "automation-result.json"), + result_json: join(evidenceDir, "result.json"), + command: runCommand, + report_command: reportCommand, + env_defaults: automationEnvDefaults(item, sourceEnv), + env_aliases: caseAutomationReadiness(item, sourceEnv).env_aliases, + pipeline_env_required: caseAutomationReadiness(item, sourceEnv).pipeline_env_required, + }, + }; +} + +function renderAutomationRun(run: TestAutomationRun): string { + const lines: string[] = []; + lines.push(`# Test Automation: ${run.case.id}`); + lines.push(""); + lines.push(`Run: ${run.run_id}`); + lines.push(`Started: ${run.started_at_local}`); + lines.push(`Script: ${run.automation.script || "None declared."}`); + lines.push(`Script path: ${run.automation.script_path || "None declared."}`); + lines.push(`Script exists: ${run.automation.exists ? "yes" : "no"}`); + lines.push(""); + lines.push("## Setup Automation"); + if (run.setup_automation.length === 0) lines.push("- None declared."); + for (const setup of run.setup_automation) { + lines.push(`- ${setup.entry}`); + lines.push(` command: ${setup.command}`); + if (setup.dry_run_command) lines.push(` dry_run_command: ${setup.dry_run_command}`); + lines.push(` evidence_dir: ${setup.evidence_dir}`); + lines.push(` exists: ${setup.exists ? "yes" : "no"}`); + } + lines.push(""); + lines.push("## Commands"); + lines.push(`- run: ${run.automation.command}`); + lines.push(`- report: ${run.automation.report_command}`); + lines.push(""); + lines.push("## Evidence Files"); + lines.push(`- console_log: ${run.automation.console_log}`); + lines.push(`- network_log: ${run.automation.network_log}`); + lines.push(`- screenshot: ${run.automation.screenshot}`); + lines.push(`- automation_result_json: ${run.automation.automation_result_json}`); + lines.push(`- result_json: ${run.automation.result_json}`); + lines.push(""); + lines.push(...renderLines("Required Env", run.automation.required_env)); + lines.push("## Automation Env Defaults"); + const defaults = Object.entries(run.automation.env_defaults); + if (defaults.length === 0) lines.push("- None declared."); + for (const [key, value] of defaults) lines.push(`- ${key}=${redactEnvValue(key, value)}`); + lines.push("## Automation Env Aliases"); + if (run.automation.env_aliases.length === 0) lines.push("- None declared."); + for (const alias of run.automation.env_aliases) { + lines.push(`- ${alias.target} <- ${alias.source} (${alias.configured ? "configured" : "missing"})`); + } + if (run.automation.pipeline_env_required) lines.push("- Pipeline env is case-specific; global LANGBOT_PIPELINE_URL fallback is disabled."); + return `${lines.join("\n").trimEnd()}\n`; +} + +function automationEnv( + root: string, + item: StructuredItem, + run: TestAutomationRun, + evidenceDir: string, + options: Record, +): Record { + const baseEnv = runtimeEnv(root); + const envDefaults = automationEnvDefaults(item, baseEnv); + return { + ...processEnv, + ...envDefaults, + ...baseEnv, + ...resolvedAutomationEnvOverrides(item, baseEnv), + ...Object.fromEntries( + Object.keys(envDefaults) + .filter((key) => baseEnv[key] !== undefined) + .map((key) => [key, baseEnv[key]]), + ), + LBS_ROOT: root, + LBS_CASE_ID: String(run.case.id), + LBS_RUN_ID: run.run_id, + LBS_STARTED_AT: run.started_at, + LBS_STARTED_AT_LOCAL: run.started_at_local, + LBS_EVIDENCE_DIR: resolve(evidenceDir), + LBS_HEADED: options.headed === true ? "1" : processEnv.LBS_HEADED, + }; +} + +function readSetupResult(setup: SetupAutomation): { status?: string; reason?: string } { + try { + return JSON.parse(readFileSync(join(setup.evidence_dir, "automation-result.json"), "utf8")); + } catch { + return {}; + } +} + +function writeSetupFailureResult(run: TestAutomationRun, setup: SetupAutomation, exitStatus: number | null): void { + const now = new Date(); + const setupResult = readSetupResult(setup); + const status = setupResult.status && setupResult.status !== "pass" + ? setupResult.status + : exitStatus === 2 ? "env_issue" : "fail"; + const result = { + source: "setup_automation", + case_id: run.case.id, + run_id: run.run_id, + status, + reason: setupResult.reason || `Setup automation failed: ${setup.entry}`, + failed_setup: setup, + exit_status: exitStatus, + started_at: run.started_at, + started_at_local: run.started_at_local, + finished_at: now.toISOString(), + finished_at_local: localIsoWithOffset(now), + evidence_collected: ["api_diagnostic"], + }; + writeFileSync(join(run.automation.evidence_dir, "automation-result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); + writeFileSync(join(run.automation.evidence_dir, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); +} + +function executionTail(value: string | Buffer | null | undefined): string { + return String(value ?? "").trim().slice(-4000); +} + +function exitStatusFromResultStatus(status: string): number { + if (status === "pass") return 0; + if (status === "blocked" || status === "env_issue" || status === "flaky") return 2; + return 1; +} + +function executionStatusFromExitStatus(status: number): string { + if (status === 0) return "ok"; + if (status === 2) return "classified"; + return "nonzero"; +} + +function executionFromAutomationResultFile( + evidenceDir: string, + caseId: string, + runId: string, +): { status: string; exit_status: number; reason: string; result_status: string; path: string } | null { + const resultPath = join(evidenceDir, "automation-result.json"); + if (!existsSync(resultPath)) return null; + try { + const parsed = JSON.parse(readFileSync(resultPath, "utf8")) as Record; + if (parsed.case_id !== caseId || parsed.run_id !== runId || typeof parsed.status !== "string") return null; + const exitStatus = exitStatusFromResultStatus(parsed.status); + return { + status: executionStatusFromExitStatus(exitStatus), + exit_status: exitStatus, + reason: typeof parsed.reason === "string" ? parsed.reason : "automation-result.json completed", + result_status: parsed.status, + path: resultPath, + }; + } catch { + return null; + } +} + +function runSetupAutomation( + ctx: CommandContext, + item: StructuredItem, + run: TestAutomationRun, + setup: SetupAutomation, + options: Record, +): { status: number; execution: Record } { + if (!setup.exists) { + if (options.json !== true) console.error(`ERROR: setup automation target not found: ${setup.entry}`); + writeSetupFailureResult(run, setup, 1); + return { + status: 1, + execution: { entry: setup.entry, status: "nonzero", exit_status: 1, reason: "setup automation target not found" }, + }; + } + mkdirSync(setup.evidence_dir, { recursive: true }); + if (options.json !== true) { + console.log(`Setup: ${setup.entry}`); + console.log(`Setup evidence: ${setup.evidence_dir}`); + } + const env = automationEnv(ctx.root, item, run, setup.evidence_dir, options); + const args = setup.kind === "case" + ? [ + lbsScriptPath(), + "--root", + ctx.root, + "test", + "run", + setup.target, + "--run-id", + `${run.run_id}-${setup.target}`, + "--output", + setup.evidence_dir, + ...(options.headed === true ? ["--headed"] : []), + ] + : [setupAutomationScriptPath(ctx.root, parseSetupAutomationEntry(setup.entry)), ...setup.args]; + const result = spawnSync(execPath, args, { + cwd: ctx.root, + env, + encoding: "utf8", + stdio: options.json === true ? "pipe" : "inherit", + }); + if (result.error) { + if (options.json !== true) console.error(`ERROR: failed to run setup automation: ${result.error.message}`); + writeSetupFailureResult(run, setup, 1); + return { + status: 1, + execution: { + entry: setup.entry, + status: "nonzero", + exit_status: 1, + reason: result.error.message, + stdout: executionTail(result.stdout), + stderr: executionTail(result.stderr), + }, + }; + } + const status = result.status ?? 1; + if (status !== 0) writeSetupFailureResult(run, setup, status); + return { + status, + execution: { + entry: setup.entry, + status: status === 0 ? "ok" : "nonzero", + exit_status: status, + stdout: executionTail(result.stdout), + stderr: executionTail(result.stderr), + }, + }; +} + +export function commandTestRun(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findCase(ctx.root, args); + const run = buildAutomationRun(ctx.root, item, args, options); + const output = typeof options.plan_output === "string" ? options.plan_output : undefined; + + if (options["dry-run"] === true) { + const content = options.json === true ? `${JSON.stringify(run, null, 2)}\n` : renderAutomationRun(run); + writeOrPrint(content, output); + return 0; + } + + if (!run.automation.script) { + console.error(`ERROR: case has no automation script: ${run.case.id}`); + return 1; + } + if (!run.automation.exists) { + console.error(`ERROR: automation script not found: ${run.automation.script_path}`); + return 1; + } + + mkdirSync(run.automation.evidence_dir, { recursive: true }); + if (options.json !== true) { + console.log(`Run: ${run.run_id}`); + console.log(`Evidence: ${run.automation.evidence_dir}`); + console.log(`Report command: ${run.automation.report_command}`); + } + + const setupExecutions: Array> = []; + for (const setup of run.setup_automation) { + const { status, execution } = runSetupAutomation(ctx, item, run, setup, options); + setupExecutions.push(execution); + if (status !== 0) { + if (options.json === true) { + console.log(JSON.stringify({ + run, + setup_executions: setupExecutions, + automation_execution: null, + exit_status: status, + }, null, 2)); + } + return status; + } + } + + const env = automationEnv(ctx.root, item, run, run.automation.evidence_dir, options); + const result = spawnSync(execPath, [run.automation.script_path], { + cwd: ctx.root, + env, + encoding: "utf8", + stdio: options.json === true ? "pipe" : "inherit", + }); + + if (result.error) { + const fileExecution = executionFromAutomationResultFile( + run.automation.evidence_dir, + String(run.case.id), + run.run_id, + ); + if (fileExecution) { + if (options.json !== true) { + console.error(`WARN: automation spawn reported an error, but ${fileExecution.path} completed: ${result.error.message}`); + } + if (options.json === true) { + console.log(JSON.stringify({ + run, + setup_executions: setupExecutions, + automation_execution: { + ...fileExecution, + spawn_error: result.error.message, + stdout: executionTail(result.stdout), + stderr: executionTail(result.stderr), + }, + exit_status: fileExecution.exit_status, + }, null, 2)); + } + return fileExecution.exit_status; + } + if (options.json !== true) console.error(`ERROR: failed to run automation: ${result.error.message}`); + if (options.json === true) { + console.log(JSON.stringify({ + run, + setup_executions: setupExecutions, + automation_execution: { + status: "nonzero", + exit_status: 1, + reason: result.error.message, + stdout: executionTail(result.stdout), + stderr: executionTail(result.stderr), + }, + exit_status: 1, + }, null, 2)); + } + return 1; + } + const status = result.status ?? 1; + if (options.json === true) { + console.log(JSON.stringify({ + run, + setup_executions: setupExecutions, + automation_execution: { + status: executionStatusFromExitStatus(status), + exit_status: status, + stdout: executionTail(result.stdout), + stderr: executionTail(result.stderr), + }, + exit_status: status, + }, null, 2)); + } + return status; +} + + +function buildReport(root: string, item: StructuredItem, options: Record): TestReport { + const env = loadEnv(root); + const mode = caseMode(item); + const related = relatedTroubleshooting(root, item).map((entry) => ({ + id: scalar(entry.fields, "id"), + title: scalar(entry.fields, "title"), + patterns: listValue(entry.fields, "patterns"), + verification: scalar(entry.fields, "verification"), + })); + + return { + generated_at: new Date().toISOString(), + case: caseSummary(item), + result_options: ["pass", "fail", "blocked", "env_issue", "flaky"], + automation_result: readAutomationResultEvidence(options), + manual_evidence: manualEvidenceTemplate(mode), + environment: envSummary(item, env), + required_skills: listValue(item.fields, "skills"), + steps: listValue(item.fields, "steps"), + checks: listValue(item.fields, "checks"), + diagnostics: listValue(item.fields, "diagnostics"), + evidence_required: listValue(item.fields, "evidence_required"), + success_patterns: listValue(item.fields, "success_patterns"), + failure_patterns: listValue(item.fields, "failure_patterns"), + expected_failures: listValue(item.fields, "expected_failures"), + troubleshooting: related, + log_guard: scanStructuredLogSources(root, item, options), + }; +} + +function renderLines(title: string, values: string[]): string[] { + const lines = [`## ${title}`]; + if (values.length === 0) lines.push("- None declared."); + else for (const value of values) lines.push(`- ${value}`); + lines.push(""); + return lines; +} + +function renderFinding(finding: LogFinding): string { + return renderLogFinding(finding); +} + +function renderSuccessSignal(signal: LogSuccessSignal): string { + return renderLogSuccessSignal(signal); +} + +function renderMarkdownReport(report: TestReport): string { + const reportCase = report.case; + const evidence = report.manual_evidence; + const environment = report.environment; + const logGuard = report.log_guard; + const troubleshooting = report.troubleshooting; + const automation = report.automation_result; + const lines: string[] = []; + + lines.push(`# Test Report: ${reportCase.id}`); + lines.push(""); + lines.push(`Generated: ${report.generated_at}`); + lines.push(`Title: ${reportCase.title}`); + lines.push(`Skill: ${reportCase.skill}`); + lines.push(`Mode: ${reportCase.mode}`); + lines.push(`Area: ${reportCase.area}`); + lines.push(`Type: ${reportCase.type}`); + lines.push(""); + lines.push("## Result"); + if (automation.status === "loaded" && automation.result) { + lines.push(`- result: ${automation.result}`); + if (automation.reason) lines.push(`- reason: ${automation.reason}`); + if (automation.url) lines.push(`- target_tested: ${automation.url}`); + if (automation.path) lines.push(`- automation_result: ${automation.path}`); + if (automation.artifacts) lines.push(`- artifacts: ${JSON.stringify(automation.artifacts)}`); + } else { + lines.push(`- result: ${evidence.result}`); + for (const [key, value] of Object.entries(evidence)) { + if (key !== "result") lines.push(`- ${key}: ${value}`); + } + } + lines.push(""); + lines.push("## Automation Result"); + lines.push(`- status: ${automation.status}`); + if (automation.path) lines.push(`- path: ${automation.path}`); + if (automation.result) lines.push(`- result: ${automation.result}`); + if (automation.reason) lines.push(`- reason: ${automation.reason}`); + if (automation.duration_ms !== undefined) lines.push(`- duration_ms: ${automation.duration_ms}`); + if (automation.started_at_local) lines.push(`- started_at_local: ${automation.started_at_local}`); + if (automation.finished_at_local) lines.push(`- finished_at_local: ${automation.finished_at_local}`); + if (automation.url) lines.push(`- url: ${automation.url}`); + if (automation.expected_text) lines.push(`- expected_text: ${automation.expected_text}`); + if (automation.metrics_summary) { + lines.push("- metrics_summary:"); + lines.push(` ${JSON.stringify(automation.metrics_summary)}`); + } + if (automation.thresholds_summary) { + lines.push("- thresholds_summary:"); + lines.push(` ${JSON.stringify(automation.thresholds_summary)}`); + } + if (automation.artifacts) { + lines.push("- artifacts:"); + lines.push(` ${JSON.stringify(automation.artifacts)}`); + } + lines.push(""); + lines.push("## Environment"); + for (const [key, value] of Object.entries(environment)) lines.push(`- ${key}=${value}`); + lines.push(""); + lines.push(`## ${stepHeading(String(reportCase.mode || "agent-browser"))}`); + for (const [index, step] of report.steps.entries()) lines.push(`${index + 1}. ${step}`); + lines.push(""); + lines.push(...renderLines("Checks", report.checks)); + lines.push(...renderLines("Diagnostics", report.diagnostics)); + lines.push(...renderLines("Required Evidence", report.evidence_required)); + lines.push(...renderLines("Success Signals", report.success_patterns)); + lines.push(...renderLines("Failure Signals", report.failure_patterns)); + lines.push(...renderLines("Expected Failures", report.expected_failures)); + lines.push("## Log Guard"); + lines.push(`- status: ${logGuard.status}`); + lines.push(`- scan_mode: ${logGuard.scan.mode}`); + if (logGuard.scan.since) lines.push(`- since: ${logGuard.scan.since}`); + if (logGuard.scan.until) lines.push(`- until: ${logGuard.scan.until}`); + if (logGuard.scan.tail_lines !== undefined) lines.push(`- tail_lines: ${logGuard.scan.tail_lines}`); + if (logGuard.scan.warnings.length > 0) { + lines.push("- scan_warnings:"); + for (const warning of logGuard.scan.warnings) lines.push(` - ${warning}`); + } + if (logGuard.sources.length === 0) { + lines.push("- sources: no log files provided; run with --backend-log, --frontend-log, or --console-log to scan logs."); + } else { + lines.push("- sources:"); + for (const source of logGuard.sources) { + const origin = source.auto_detected ? ", auto" : ""; + const total = source.total_line_count === undefined ? "" : `/${source.total_line_count}`; + const range = source.start_line === undefined || source.end_line === undefined + ? "" + : `, lines ${source.start_line}-${source.end_line}`; + const timestamped = source.timestamped_line_count === undefined ? "" : `, ${source.timestamped_line_count} timestamped`; + lines.push(` - ${source.source}: ${source.path} (${source.status}${origin}, ${source.line_count}${total} lines${range}${timestamped})`); + } + } + lines.push("- findings:"); + if (logGuard.findings.length === 0) lines.push(" - None."); + else for (const finding of logGuard.findings) lines.push(` ${renderFinding(finding)}`); + lines.push("- success_signals:"); + if (logGuard.success_signals.length === 0) lines.push(" - None."); + else for (const signal of logGuard.success_signals) lines.push(` ${renderSuccessSignal(signal)}`); + lines.push(""); + lines.push("## Related Troubleshooting"); + if (troubleshooting.length === 0) lines.push("- None declared."); + for (const entry of troubleshooting) { + lines.push(`- ${entry.id}: ${entry.title}`); + if (entry.patterns.length > 0) lines.push(` patterns: ${entry.patterns.join(" | ")}`); + if (entry.verification) lines.push(` verification: ${entry.verification}`); + } + lines.push(""); + lines.push("## Decision Notes"); + if (isProbeMode(String(reportCase.mode))) { + lines.push("- Probe results should be judged from the declared checks and required evidence for the same run."); + } else { + lines.push("- API/curl diagnostics can explain the run, but cannot make this UI case pass by themselves."); + } + lines.push("- Do not paste API keys, OAuth secrets, tokens, or localStorage token values into this report."); + lines.push(""); + + return `${lines.join("\n").trimEnd()}\n`; +} + +function writeOrPrint(content: string, output: string | undefined): void { + if (!output) { + console.log(content.trimEnd()); + return; + } + const path = resolve(output); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); + console.log(path); +} + +export function commandTestReport(ctx: CommandContext): number { + const { positional: args, options } = parseOptions(ctx.args.slice(2)); + const item = findCase(ctx.root, args); + const report = buildReport(ctx.root, item, options); + const output = typeof options.output === "string" ? options.output : undefined; + const content = options.json === true ? `${JSON.stringify(report, null, 2)}\n` : renderMarkdownReport(report); + + writeOrPrint(content, output); + return 0; +} diff --git a/skills/src/commands/trouble.ts b/skills/src/commands/trouble.ts new file mode 100644 index 0000000..6429ab4 --- /dev/null +++ b/skills/src/commands/trouble.ts @@ -0,0 +1,95 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { CommandContext } from "../types.ts"; +import { fail, optionString, parseOptions, usage } from "../cli.ts"; +import { findStructuredItem, getSkill, loadStructuredItems, scalar, slugify, todayIso, yamlList, yamlQuote } from "../fs.ts"; + +function troubleshootingYamlPath(root: string, skillName: string, id: string): string { + const skill = getSkill(root, skillName); + const dir = join(skill.path, "troubleshooting"); + mkdirSync(dir, { recursive: true }); + return join(dir, `${id}.yaml`); +} + +function legacyTroubleshootingPath(root: string, skillName: string): string { + const skill = getSkill(root, skillName); + const refsDir = join(skill.path, "references"); + mkdirSync(refsDir, { recursive: true }); + const path = join(refsDir, "troubleshooting.md"); + if (!existsSync(path)) writeFileSync(path, "# Troubleshooting\n\n", "utf8"); + return path; +} + +export function commandTroubleList(ctx: CommandContext): number { + const skill = ctx.args[2]; + const yamlItems = loadStructuredItems(ctx.root, "troubleshooting", skill); + for (const item of yamlItems) { + console.log(`${item.skill}\t${scalar(item.fields, "id")}\t${scalar(item.fields, "title")}`); + } + + if (skill && yamlItems.length === 0) { + const legacyPath = legacyTroubleshootingPath(ctx.root, skill); + const text = readFileSync(legacyPath, "utf8"); + const headings = Array.from(text.matchAll(/^##\s+(.+)$/gm)).map((match) => match[1]); + for (const heading of headings) console.log(`${skill}\tlegacy\t${heading}`); + } + return 0; +} + +export function commandTroubleShow(ctx: CommandContext): number { + const positional = ctx.args.slice(2); + if (positional.length < 1 || positional.length > 2) usage(); + const item = positional.length === 1 + ? findStructuredItem(ctx.root, "troubleshooting", positional[0]) + : findStructuredItem(ctx.root, "troubleshooting", positional[0], positional[1]); + console.log(item.raw.trimEnd()); + return 0; +} + +export function commandTroubleSearch(ctx: CommandContext): number { + const query = ctx.args[2]?.toLowerCase(); + if (!query) usage(); + const items = loadStructuredItems(ctx.root, "troubleshooting").filter((item) => item.raw.toLowerCase().includes(query)); + for (const item of items) { + console.log(`${item.skill}\t${scalar(item.fields, "id")}\t${scalar(item.fields, "title")}`); + } + return 0; +} + +export function commandTroubleAdd(ctx: CommandContext): number { + const skill = ctx.args[2]; + if (!skill) usage(); + const { options } = parseOptions(ctx.args.slice(3)); + for (const key of ["title", "symptom", "cause", "fix"]) { + if (!optionString(options, key)) fail(`--${key} is required`); + } + + const title = optionString(options, "title") ?? ""; + const symptom = optionString(options, "symptom") ?? ""; + const id = optionString(options, "id") ?? slugify(title); + const path = troubleshootingYamlPath(ctx.root, skill, id); + if (existsSync(path)) fail(`troubleshooting entry already exists: ${path}`); + + const text = + `id: ${id}\n` + + `title: ${yamlQuote(title)}\n` + + `date: ${todayIso()}\n` + + "symptoms:\n" + + yamlList([symptom]) + + "\npatterns:\n" + + yamlList([symptom]) + + "\nlikely_causes:\n" + + yamlList([optionString(options, "cause") ?? ""]) + + "\nfix_steps:\n" + + yamlList([optionString(options, "fix") ?? ""]) + + "\nverification: " + + yamlQuote(optionString(options, "verify") ?? "Add the command, UI signal, or log line that proves the fix worked.") + + "\nrelated_cases:\n" + + yamlList([]) + + "\n"; + + writeFileSync(path, text, "utf8"); + appendFileSync(legacyTroubleshootingPath(ctx.root, skill), `\n## ${id}: ${title}\n\nSee \`../troubleshooting/${id}.yaml\`.\n`, "utf8"); + console.log(path); + return 0; +} diff --git a/skills/src/commands/validate.ts b/skills/src/commands/validate.ts new file mode 100644 index 0000000..590032e --- /dev/null +++ b/skills/src/commands/validate.ts @@ -0,0 +1,449 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { stderr } from "node:process"; +import type { Skill, StructuredItem } from "../types.ts"; +import { loadFixtureItems } from "../fixtures.ts"; +import { + caseEvidenceValues, + caseModeValues, + casePriorityValues, + caseRequiredLists, + caseRequiredStrings, + caseRiskValues, + caseTypeValues, + requiredEnvKeys, + suiteRequiredLists, + suiteRequiredStrings, + suiteTypeValues, + troubleRequiredLists, + troubleRequiredStrings, + troubleshootingCategoryValues, +} from "../constants.ts"; +import { boolValue, envExamplePath, envPath, listValue, loadSkills, loadStructuredItems, parseEnvFile, scalar } from "../fs.ts"; +import { envKeyPattern, isEnvAnyGroup, splitEnvAnyGroup } from "../env-groups.ts"; +import { parseSetupAutomationEntry, validateSetupAutomationEntry } from "../setup-automation.ts"; + +const refRe = /(?:\]\(|`)(references\/[A-Za-z0-9_.\-/]+\.md)(?:\)|`)/g; + +function validateStructuredItem(item: StructuredItem, requiredStrings: string[], requiredLists: string[]): string[] { + const errors: string[] = []; + const listKeys = item.path.includes("/cases/") && scalar(item.fields, "mode") === "probe" + ? requiredLists.filter((key) => key !== "env") + : requiredLists; + for (const key of requiredStrings) { + if (!scalar(item.fields, key)) errors.push(`${item.path}: missing '${key}'`); + } + for (const key of listKeys) { + if (listValue(item.fields, key).length === 0) errors.push(`${item.path}: missing '${key}' entries`); + } + const id = scalar(item.fields, "id"); + if (id && !/^[a-z0-9][a-z0-9_-]*$/.test(id)) { + errors.push(`${item.path}: id must use lowercase letters, digits, dashes, or underscores`); + } + return errors; +} + +function validateEnum(item: StructuredItem, key: string, values: string[]): string[] { + const value = scalar(item.fields, key); + if (!value) return []; + return values.includes(value) ? [] : [`${item.path}: '${key}' must be one of ${values.join(", ")}`]; +} + +function validateListEnum(item: StructuredItem, key: string, values: string[]): string[] { + const allowed = new Set(values); + return listValue(item.fields, key) + .filter((value) => !allowed.has(value)) + .map((value) => `${item.path}: '${key}' contains unsupported value '${value}'`); +} + +function validateDuplicateListValues(item: StructuredItem, keys: string[]): string[] { + const errors: string[] = []; + for (const key of keys) { + const seen = new Set(); + for (const value of listValue(item.fields, key)) { + if (seen.has(value)) errors.push(`${item.path}: '${key}' contains duplicate value '${value}'`); + seen.add(value); + } + } + return errors; +} + +function validateEnvKeyList(item: StructuredItem, key: string): string[] { + return listValue(item.fields, key) + .filter((value) => !envKeyPattern.test(value)) + .map((value) => `${item.path}: '${key}' contains invalid env key '${value}'`); +} + +function validateEnvKeyScalar(item: StructuredItem, key: string): string[] { + const value = scalar(item.fields, key); + if (!value) return []; + return envKeyPattern.test(value) + ? [] + : [`${item.path}: '${key}' contains invalid env key '${value}'`]; +} + +function validateJsonScalar(item: StructuredItem, key: string): string[] { + const value = scalar(item.fields, key); + if (!value) return []; + try { + JSON.parse(value); + return []; + } catch (error) { + return [`${item.path}: '${key}' must be valid JSON: ${(error as Error).message}`]; + } +} + +function validateEnvAnyList(item: StructuredItem, key: string): string[] { + return listValue(item.fields, key) + .filter((value) => !isEnvAnyGroup(value)) + .map((value) => `${item.path}: '${key}' contains invalid env any-group '${value}'`); +} + +function validateCaseItem(root: string, item: StructuredItem, skillNames: Set, troubleIds: Set, caseIds: Set): string[] { + const errors = [ + ...validateEnum(item, "mode", caseModeValues), + ...validateEnum(item, "type", caseTypeValues), + ...validateEnum(item, "priority", casePriorityValues), + ...validateEnum(item, "risk", caseRiskValues), + ...validateListEnum(item, "evidence_required", caseEvidenceValues), + ...validateDuplicateListValues(item, [ + "tags", + "skills", + "env", + "env_any", + "automation_env", + "automation_env_any", + "setup_automation", + "setup_provides_env", + "evidence_required", + "troubleshooting", + ]), + ...validateEnvKeyList(item, "env"), + ...validateEnvAnyList(item, "env_any"), + ...validateEnvKeyList(item, "automation_env"), + ...validateEnvAnyList(item, "automation_env_any"), + ...validateEnvKeyList(item, "setup_provides_env"), + ...validateEnvKeyScalar(item, "automation_pipeline_url_env"), + ...validateEnvKeyScalar(item, "automation_pipeline_name_env"), + ...validateJsonScalar(item, "automation_filesystem_checks_json"), + ...validateJsonScalar(item, "metrics_thresholds_json"), + ...validateJsonScalar(item, "load_profile_json"), + ...validateJsonScalar(item, "fault_model_json"), + ...listValue(item.fields, "setup_automation").flatMap((entry) => ( + validateSetupAutomationEntry(root, entry, caseIds).map((error) => `${item.path}: ${error}`) + )), + ]; + + if (boolValue(item.fields, "ci_eligible") === undefined) { + errors.push(`${item.path}: missing or invalid boolean 'ci_eligible'`); + } + + for (const skill of listValue(item.fields, "skills")) { + if (!skillNames.has(skill)) errors.push(`${item.path}: references unknown skill '${skill}'`); + } + + for (const id of listValue(item.fields, "troubleshooting")) { + if (!troubleIds.has(id)) errors.push(`${item.path}: references unknown troubleshooting '${id}'`); + } + + const automation = scalar(item.fields, "automation"); + if (!automation && listValue(item.fields, "automation_env").length > 0) { + errors.push(`${item.path}: 'automation_env' requires 'automation'`); + } + if (!automation && listValue(item.fields, "automation_env_any").length > 0) { + errors.push(`${item.path}: 'automation_env_any' requires 'automation'`); + } + if (!automation && (scalar(item.fields, "automation_pipeline_url_env") || scalar(item.fields, "automation_pipeline_name_env"))) { + errors.push(`${item.path}: automation pipeline env aliases require 'automation'`); + } + if (listValue(item.fields, "setup_provides_env").length > 0 && listValue(item.fields, "setup_automation").length === 0) { + errors.push(`${item.path}: 'setup_provides_env' requires 'setup_automation'`); + } + for (const key of ["automation_pipeline_url_env", "automation_pipeline_name_env"]) { + const value = scalar(item.fields, key); + if (!value) continue; + const declared = new Set([ + ...listValue(item.fields, "env"), + ...listValue(item.fields, "env_any").flatMap(splitEnvAnyGroup), + ...listValue(item.fields, "automation_env"), + ...listValue(item.fields, "automation_env_any").flatMap(splitEnvAnyGroup), + ]); + if (!declared.has(value)) { + errors.push(`${item.path}: '${key}' value '${value}' must be listed in env, env_any, automation_env, or automation_env_any`); + } + } + if (automation && !existsSync(join(root, automation))) { + errors.push(`${item.path}: automation script does not exist: ${automation}`); + } + for (const entry of listValue(item.fields, "setup_automation")) { + const spec = parseSetupAutomationEntry(entry); + if (spec.kind === "case" && spec.target === scalar(item.fields, "id")) { + errors.push(`${item.path}: setup_automation cannot reference the same case '${spec.target}'`); + } + } + + const timeout = scalar(item.fields, "automation_response_timeout_ms"); + if (timeout && (!/^\d+$/.test(timeout) || Number.parseInt(timeout, 10) <= 0)) { + errors.push(`${item.path}: 'automation_response_timeout_ms' must be a positive integer string`); + } + for (const key of [ + "automation_debug_chat_load_requests", + "automation_debug_chat_load_concurrency", + "automation_debug_chat_load_timeout_ms", + "automation_debug_chat_load_response_p95_ms", + "automation_debug_chat_load_first_response_p95_ms", + ]) { + const value = scalar(item.fields, key); + if (value && (!/^\d+$/.test(value) || Number.parseInt(value, 10) <= 0)) { + errors.push(`${item.path}: '${key}' must be a positive integer string`); + } + } + for (const key of [ + "automation_debug_chat_load_min_error_count", + "automation_debug_chat_load_min_ok_count", + "automation_debug_chat_load_min_provider_fault_count", + "automation_fake_provider_first_token_delay_ms", + "automation_fake_provider_chunk_delay_ms", + "automation_fake_provider_chunk_count", + "automation_fake_provider_fail_first_n", + "automation_fake_provider_fail_every_n", + ]) { + const value = scalar(item.fields, key); + if (value && (!/^\d+$/.test(value) || Number.parseInt(value, 10) < 0)) { + errors.push(`${item.path}: '${key}' must be a non-negative integer string`); + } + } + for (const key of ["automation_debug_chat_load_max_error_rate", "automation_debug_chat_load_min_error_rate"]) { + const value = scalar(item.fields, key); + if (value && (!/^(?:0(?:\.\d+)?|1(?:\.0+)?)$/.test(value))) { + errors.push(`${item.path}: '${key}' must be a number string between 0 and 1`); + } + } + const fakeProviderFaultStatus = scalar(item.fields, "automation_fake_provider_fault_status"); + if (fakeProviderFaultStatus) { + const parsed = Number.parseInt(fakeProviderFaultStatus, 10); + if (!/^\d+$/.test(fakeProviderFaultStatus) || parsed < 400 || parsed > 599) { + errors.push(`${item.path}: 'automation_fake_provider_fault_status' must be an HTTP 4xx or 5xx status string`); + } + } + const streamOutput = scalar(item.fields, "automation_stream_output"); + if (streamOutput && !["0", "1", "false", "true"].includes(streamOutput)) { + errors.push(`${item.path}: 'automation_stream_output' must be one of 0, 1, false, or true`); + } + for (const key of [ + "automation_debug_chat_load_stream", + "automation_debug_chat_load_reset", + "automation_debug_chat_load_fail_on_final_mismatch", + "automation_fake_provider_fail_after_first_chunk", + "automation_fake_provider_dynamic_response", + ]) { + const value = scalar(item.fields, key); + if (value && !["0", "1", "false", "true"].includes(value)) { + errors.push(`${item.path}: '${key}' must be one of 0, 1, false, or true`); + } + } + const imageBase64Fixture = scalar(item.fields, "automation_image_base64_fixture"); + if (imageBase64Fixture && !existsSync(join(root, imageBase64Fixture))) { + errors.push(`${item.path}: automation image fixture does not exist: ${imageBase64Fixture}`); + } + + return errors; +} + +function validateSetupAutomationCycles(caseItems: StructuredItem[]): string[] { + const byId = new Map(caseItems.map((item) => [scalar(item.fields, "id"), item])); + const visiting = new Set(); + const visited = new Set(); + const errors: string[] = []; + + function visit(id: string, path: string[]): void { + if (visited.has(id)) return; + if (visiting.has(id)) { + const cycle = [...path.slice(path.indexOf(id)), id].join(" -> "); + const item = byId.get(id); + errors.push(`${item?.path ?? id}: setup_automation case cycle detected: ${cycle}`); + return; + } + const item = byId.get(id); + if (!item) return; + visiting.add(id); + for (const entry of listValue(item.fields, "setup_automation")) { + const spec = parseSetupAutomationEntry(entry); + if (spec.kind === "case") visit(spec.target, [...path, spec.target]); + } + visiting.delete(id); + visited.add(id); + } + + for (const id of byId.keys()) visit(id, [id]); + return errors; +} + +function validateTroubleshootingItem(item: StructuredItem, caseIds: Set): string[] { + const errors = [ + ...validateEnum(item, "category", troubleshootingCategoryValues), + ...validateDuplicateListValues(item, ["symptoms", "patterns", "likely_causes", "fix_steps", "related_cases"]), + ]; + for (const id of listValue(item.fields, "related_cases")) { + if (!caseIds.has(id)) errors.push(`${item.path}: references unknown case '${id}'`); + } + return errors; +} + +function validateFixtures(root: string, caseIds: Set): string[] { + const { items, errors } = loadFixtureItems(root); + const result = [...errors]; + const seen = new Map(); + for (const item of items) { + if (!/^[a-z0-9][a-z0-9_-]*$/.test(item.id)) { + result.push(`${item.manifest_path}: fixture id '${item.id}' must use lowercase letters, digits, dashes, or underscores`); + } + if (seen.has(item.id)) { + result.push(`${item.manifest_path}: duplicate fixture id '${item.id}' also used by ${seen.get(item.id)}`); + } else { + seen.set(item.id, item.manifest_path); + } + if (!item.exists) result.push(`${item.manifest_path}: fixture path does not exist: ${item.path}`); + for (const caseId of item.related_cases) { + if (!caseIds.has(caseId)) result.push(`${item.manifest_path}: fixture '${item.id}' references unknown case '${caseId}'`); + } + } + return result; +} + +function validateSuiteItem(item: StructuredItem, caseIds: Set): string[] { + const errors = [ + ...validateEnum(item, "type", suiteTypeValues), + ...validateEnum(item, "priority", casePriorityValues), + ...validateDuplicateListValues(item, ["tags", "cases"]), + ]; + for (const id of listValue(item.fields, "cases")) { + if (!caseIds.has(id)) errors.push(`${item.path}: references unknown case '${id}'`); + } + return errors; +} + +function validateDuplicateIds(items: StructuredItem[], label: string): string[] { + const errors: string[] = []; + const seen = new Map(); + for (const item of items) { + const id = scalar(item.fields, "id"); + if (!id) continue; + const key = `${item.skill}:${id}`; + if (seen.has(key)) errors.push(`${item.path}: duplicate ${label} id '${id}' also used by ${seen.get(key)}`); + else seen.set(key, item.path); + } + return errors; +} + +function validateGlobalDuplicateIds(items: StructuredItem[], label: string): string[] { + const errors: string[] = []; + const seen = new Map(); + for (const item of items) { + const id = scalar(item.fields, "id"); + if (!id) continue; + if (seen.has(id)) errors.push(`${item.path}: duplicate global ${label} id '${id}' also used by ${seen.get(id)}`); + else seen.set(id, item.path); + } + return errors; +} + +function validateEnv(root: string): string[] { + const path = envPath(root); + const examplePath = envExamplePath(root); + const errors: string[] = []; + if (!existsSync(path)) return [`${path}: missing shared env file`]; + const env = parseEnvFile(path); + for (const key of requiredEnvKeys) { + if (!(key in env)) errors.push(`${path}: missing ${key}`); + } + if (!existsSync(examplePath)) { + errors.push(`${examplePath}: missing env template`); + } else { + const example = parseEnvFile(examplePath); + for (const key of requiredEnvKeys) { + if (!(key in example)) errors.push(`${examplePath}: missing template key ${key}`); + } + } + return errors; +} + +function validateSchemas(root: string): string[] { + const errors: string[] = []; + for (const name of ["case.schema.json", "suite.schema.json", "troubleshooting.schema.json", "skill-index.schema.json"]) { + const path = join(root, "schemas", name); + if (!existsSync(path)) { + errors.push(`${path}: missing schema`); + continue; + } + try { + JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + errors.push(`${path}: invalid JSON schema (${String(error)})`); + } + } + return errors; +} + +function validateSkill(skill: Skill): string[] { + const errors: string[] = []; + if (!skill.name) errors.push(`${skill.path}: missing frontmatter name`); + if (!skill.description) errors.push(`${skill.path}: missing frontmatter description`); + if (skill.name && skill.name !== skill.directory) { + errors.push(`${skill.path}: name '${skill.name}' does not match directory '${skill.directory}'`); + } + + const refs = new Set(); + for (const match of skill.body.matchAll(refRe)) refs.add(match[1]); + for (const ref of Array.from(refs).sort()) { + if (!existsSync(join(skill.path, ref))) { + errors.push(`${skill.path}: referenced file does not exist: ${ref}`); + } + } + + const legacyTroubleshooting = join(skill.path, "references", "troubleshooting.md"); + if (existsSync(legacyTroubleshooting)) { + const text = readFileSync(legacyTroubleshooting, "utf8"); + if (text.includes("\n## ") && !text.includes("### Symptom")) { + errors.push(`${legacyTroubleshooting}: troubleshooting entries should include '### Symptom'`); + } + } + + return errors; +} + +export function commandValidate(root: string): number { + const skills = loadSkills(root); + const caseItems = loadStructuredItems(root, "cases"); + const suiteItems = loadStructuredItems(root, "suites"); + const troubleItems = loadStructuredItems(root, "troubleshooting"); + const skillNames = new Set(skills.map((skill) => skill.name)); + const caseIds = new Set(caseItems.map((item) => scalar(item.fields, "id")).filter(Boolean)); + const troubleIds = new Set(troubleItems.map((item) => scalar(item.fields, "id")).filter(Boolean)); + const errors = [ + ...validateEnv(root), + ...validateSchemas(root), + ...skills.flatMap(validateSkill), + ...caseItems.flatMap((item) => validateStructuredItem(item, caseRequiredStrings, caseRequiredLists)), + ...caseItems.flatMap((item) => validateCaseItem(root, item, skillNames, troubleIds, caseIds)), + ...validateSetupAutomationCycles(caseItems), + ...suiteItems.flatMap((item) => validateStructuredItem(item, suiteRequiredStrings, suiteRequiredLists)), + ...suiteItems.flatMap((item) => validateSuiteItem(item, caseIds)), + ...troubleItems.flatMap((item) => validateStructuredItem(item, troubleRequiredStrings, troubleRequiredLists)), + ...troubleItems.flatMap((item) => validateTroubleshootingItem(item, caseIds)), + ...validateFixtures(root, caseIds), + ...validateDuplicateIds(caseItems, "case"), + ...validateDuplicateIds(suiteItems, "suite"), + ...validateDuplicateIds(troubleItems, "troubleshooting"), + ...validateGlobalDuplicateIds(caseItems, "case"), + ...validateGlobalDuplicateIds(suiteItems, "suite"), + ...validateGlobalDuplicateIds(troubleItems, "troubleshooting"), + ]; + + if (errors.length > 0) { + for (const error of errors) stderr.write(`ERROR: ${error}\n`); + return 1; + } + console.log("OK"); + return 0; +} diff --git a/skills/src/constants.ts b/skills/src/constants.ts new file mode 100644 index 0000000..5cfe37f --- /dev/null +++ b/skills/src/constants.ts @@ -0,0 +1,59 @@ +export const requiredEnvKeys = [ + "LANGBOT_FRONTEND_URL", + "LANGBOT_BACKEND_URL", + "LANGBOT_DEV_FRONTEND_URL", + "LANGBOT_REPO", + "LANGBOT_WEB_REPO", + "LANGBOT_BROWSER_PROFILE", + "LANGBOT_CHROMIUM_EXECUTABLE", +]; + +export const caseModeValues = ["agent-browser", "probe"]; +export const caseTypeValues = [ + "smoke", + "regression", + "feature", + "provider", + "exploratory", + "contract", + "performance", + "reliability", + "chaos", + "security", +]; +export const casePriorityValues = ["p0", "p1", "p2"]; +export const caseRiskValues = ["low", "medium", "high"]; +export const caseEvidenceValues = [ + "ui", + "screenshot", + "console", + "network", + "backend_log", + "frontend_log", + "api_diagnostic", + "filesystem", + "metrics", + "trace", + "profile", + "resource_log", +]; +export const testResultStatusValues = ["pass", "fail", "blocked", "env_issue", "flaky"]; +export const troubleshootingCategoryValues = ["product", "env_issue", "external_dependency", "blocked", "flaky"]; +export const suiteTypeValues = [ + "smoke", + "regression", + "release_gate", + "exploratory", + "contract", + "performance", + "reliability", + "chaos", + "security", +]; +export const suiteRequiredStrings = ["id", "title", "description", "type", "priority"]; +export const suiteRequiredLists = ["tags", "cases"]; + +export const caseRequiredStrings = ["id", "title", "mode", "area", "type", "priority", "risk"]; +export const caseRequiredLists = ["tags", "skills", "env", "steps", "checks", "evidence_required"]; +export const troubleRequiredStrings = ["id", "title", "verification"]; +export const troubleRequiredLists = ["symptoms", "patterns", "likely_causes", "fix_steps"]; diff --git a/skills/src/env-groups.ts b/skills/src/env-groups.ts new file mode 100644 index 0000000..0691936 --- /dev/null +++ b/skills/src/env-groups.ts @@ -0,0 +1,10 @@ +export const envKeyPattern = /^[A-Z][A-Z0-9_]*$/; + +export function splitEnvAnyGroup(value: string): string[] { + return value.split("|").map((item) => item.trim()).filter(Boolean); +} + +export function isEnvAnyGroup(value: string): boolean { + const keys = splitEnvAnyGroup(value); + return keys.length >= 2 && new Set(keys).size === keys.length && keys.every((key) => envKeyPattern.test(key)); +} diff --git a/skills/src/fixtures.ts b/skills/src/fixtures.ts new file mode 100644 index 0000000..698ad3d --- /dev/null +++ b/skills/src/fixtures.ts @@ -0,0 +1,86 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadSkills } from "./fs.ts"; + +export type FixtureItem = { + skill: string; + manifest_path: string; + id: string; + title: string; + path: string; + kind: string; + related_cases: string[]; + checks: string[]; + absolute_path: string; + exists: boolean; +}; + +export type FixtureLoadResult = { + items: FixtureItem[]; + errors: string[]; +}; + +function stringField(data: Record, key: string): string { + const value = data[key]; + return typeof value === "string" ? value : ""; +} + +function stringList(data: Record, key: string): string[] { + const value = data[key]; + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +export function loadFixtureItems(root: string, skillFilter?: string): FixtureLoadResult { + const items: FixtureItem[] = []; + const errors: string[] = []; + const skills = loadSkills(root).filter((skill) => !skillFilter || skill.directory === skillFilter || skill.name === skillFilter); + + for (const skill of skills) { + const manifestPath = join(skill.path, "fixtures", "fixtures.json"); + if (!existsSync(manifestPath)) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (error) { + errors.push(`${manifestPath}: invalid fixture manifest JSON (${String(error)})`); + continue; + } + + if (!Array.isArray(parsed)) { + errors.push(`${manifestPath}: fixture manifest must be a JSON array`); + continue; + } + + for (const [index, entry] of parsed.entries()) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + errors.push(`${manifestPath}: fixture entry ${index} must be an object`); + continue; + } + const data = entry as Record; + const id = stringField(data, "id"); + const title = stringField(data, "title"); + const path = stringField(data, "path"); + const kind = stringField(data, "kind") || "file"; + if (!id || !title || !path) { + errors.push(`${manifestPath}: fixture entry ${index} must include id, title, and path`); + continue; + } + const absolutePath = join(skill.path, path); + items.push({ + skill: skill.directory, + manifest_path: manifestPath, + id, + title, + path, + kind, + related_cases: stringList(data, "related_cases"), + checks: stringList(data, "checks"), + absolute_path: absolutePath, + exists: existsSync(absolutePath), + }); + } + } + + return { items, errors }; +} diff --git a/skills/src/fs.ts b/skills/src/fs.ts new file mode 100644 index 0000000..238e253 --- /dev/null +++ b/skills/src/fs.ts @@ -0,0 +1,226 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { ParsedYaml, Skill, StructuredItem, StructuredItemKind } from "./types.ts"; +import { fail } from "./cli.ts"; + +const frontmatterRe = /^---\n([\s\S]*?)\n---\n/; + +export function statIsDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +export function skillsRoot(root: string): string { + const nested = join(root, "skills"); + return existsSync(nested) && statIsDirectory(nested) ? nested : root; +} + +export function envPath(root: string): string { + return join(skillsRoot(root), ".env"); +} + +export function envLocalPath(root: string): string { + return join(skillsRoot(root), ".env.local"); +} + +export function envExamplePath(root: string): string { + return join(skillsRoot(root), ".env.example"); +} + +export function loadEnv(root: string): Record { + return { + ...parseEnvFile(envPath(root)), + ...parseEnvFile(envLocalPath(root)), + }; +} + +export function listDirectories(root: string): string[] { + return readdirSync(root) + .filter((name) => !name.startsWith(".")) + .filter((name) => statIsDirectory(join(root, name))) + .sort(); +} + +export function parseFrontmatter(text: string): { meta: Record; body: string } { + const match = text.match(frontmatterRe); + if (!match) return { meta: {}, body: text }; + + const meta: Record = {}; + for (const line of match[1].split("\n")) { + const sep = line.indexOf(":"); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + const value = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + meta[key] = value; + } + + return { meta, body: text.slice(match[0].length) }; +} + +export function loadSkills(root: string): Skill[] { + const skills: Skill[] = []; + const base = skillsRoot(root); + for (const directory of listDirectories(base)) { + const skillPath = join(base, directory); + const skillMd = join(skillPath, "SKILL.md"); + if (!existsSync(skillMd)) continue; + const text = readFileSync(skillMd, "utf8"); + const { meta, body } = parseFrontmatter(text); + skills.push({ + path: skillPath, + directory, + name: meta.name ?? "", + description: meta.description ?? "", + body, + }); + } + return skills; +} + +export function getSkill(root: string, skillName: string): Skill { + const skill = loadSkills(root).find((item) => item.directory === skillName || item.name === skillName); + if (!skill) fail(`unknown skill: ${skillName}`); + return skill; +} + +export function parseEnvFile(path: string): Record { + if (!existsSync(path)) return {}; + const env: Record = {}; + for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const sep = line.indexOf("="); + if (sep === -1) continue; + const key = line.slice(0, sep).trim(); + const value = line.slice(sep + 1).trim().replace(/^["']|["']$/g, ""); + env[key] = value; + } + return env; +} + +export function globMarkdownRefs(skillPath: string): string[] { + const refsDir = join(skillPath, "references"); + if (!existsSync(refsDir)) return []; + return readdirSync(refsDir) + .filter((name) => name.endsWith(".md")) + .sort() + .map((name) => join("references", name)); +} + +export function globYamlFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((name) => name.endsWith(".yaml") || name.endsWith(".yml")) + .sort() + .map((name) => join(dir, name)); +} + +function unquote(value: string): string { + return value.trim().replace(/^["']|["']$/g, ""); +} + +function parseScalarValue(value: string): string | boolean { + const trimmed = value.trim(); + if (/^["'].*["']$/.test(trimmed)) return unquote(trimmed); + if (trimmed === "true") return true; + if (trimmed === "false") return false; + return trimmed; +} + +export function parseYamlLite(text: string): ParsedYaml { + const fields: ParsedYaml = {}; + let currentList: string | null = null; + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.replace(/\s+$/, ""); + if (!line.trim() || line.trim().startsWith("#")) continue; + + const pair = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/); + if (pair) { + const key = pair[1]; + const value = pair[2]; + if (value === "") { + fields[key] = []; + currentList = key; + } else { + fields[key] = parseScalarValue(value); + currentList = null; + } + continue; + } + + const item = line.match(/^\s*-\s*(.*)$/); + if (item && currentList) { + const existing = fields[currentList]; + if (Array.isArray(existing)) existing.push(unquote(item[1])); + } + } + + return fields; +} + +export function scalar(fields: ParsedYaml, key: string): string { + const value = fields[key]; + return typeof value === "string" ? value : ""; +} + +export function boolValue(fields: ParsedYaml, key: string): boolean | undefined { + const value = fields[key]; + return typeof value === "boolean" ? value : undefined; +} + +export function listValue(fields: ParsedYaml, key: string): string[] { + const value = fields[key]; + return Array.isArray(value) ? value : []; +} + +export function yamlQuote(value: string): string { + return JSON.stringify(value); +} + +export function yamlList(values: string[]): string { + return values.map((value) => ` - ${yamlQuote(value)}`).join("\n"); +} + +export function loadStructuredItems(root: string, kind: StructuredItemKind, skillFilter?: string): StructuredItem[] { + const skills = skillFilter ? [getSkill(root, skillFilter)] : loadSkills(root); + const items: StructuredItem[] = []; + for (const skill of skills) { + for (const path of globYamlFiles(join(skill.path, kind))) { + const raw = readFileSync(path, "utf8"); + items.push({ path, skill: skill.directory, fields: parseYamlLite(raw), raw }); + } + } + return items.sort((a, b) => `${a.skill}:${scalar(a.fields, "id")}`.localeCompare(`${b.skill}:${scalar(b.fields, "id")}`)); +} + +export function findStructuredItem( + root: string, + kind: StructuredItemKind, + skillOrId: string, + maybeId?: string, +): StructuredItem { + const skillFilter = maybeId ? skillOrId : undefined; + const id = maybeId ?? skillOrId; + const matches = loadStructuredItems(root, kind, skillFilter).filter((item) => scalar(item.fields, "id") === id); + if (matches.length === 0) fail(`unknown ${kind.slice(0, -1)}: ${id}`); + if (matches.length > 1) { + fail(`ambiguous ${kind.slice(0, -1)} '${id}', specify skill: ${matches.map((item) => item.skill).join(", ")}`); + } + return matches[0]; +} + +export function slugify(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} diff --git a/skills/src/lbs.ts b/skills/src/lbs.ts new file mode 100644 index 0000000..8329a58 --- /dev/null +++ b/skills/src/lbs.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { argv, exit } from "node:process"; +import { parseGlobalArgs, usage } from "./cli.ts"; +import { commandCaseList, commandCaseNew, commandCaseShow } from "./commands/case.ts"; +import { commandEnvDoctor, commandEnvShow } from "./commands/env.ts"; +import { commandFixtureCheck, commandFixtureList } from "./commands/fixture.ts"; +import { commandIndex, commandList, commandNewRef, commandNewSkill } from "./commands/skill.ts"; +import { commandLogGuard, commandLogScan, commandLogWatch } from "./commands/log.ts"; +import { commandSuiteList, commandSuiteNew, commandSuitePlan, commandSuiteReport, commandSuiteRun, commandSuiteShow, commandSuiteStart } from "./commands/suite.ts"; +import { commandTestPlan, commandTestRecommend, commandTestReport, commandTestResult, commandTestRun, commandTestStart } from "./commands/test.ts"; +import { commandTroubleAdd, commandTroubleList, commandTroubleSearch, commandTroubleShow } from "./commands/trouble.ts"; +import { commandValidate } from "./commands/validate.ts"; + +async function main(): Promise { + const ctx = parseGlobalArgs(argv.slice(2)); + const command = ctx.args[0]; + if (!command) usage(); + + if (command === "list") return commandList(ctx); + if (command === "validate") return commandValidate(ctx.root); + if (command === "index") return commandIndex(ctx); + if (command === "new-skill") return commandNewSkill(ctx); + if (command === "new-ref") return commandNewRef(ctx); + + if (command === "env") { + const sub = ctx.args[1]; + if (sub === "show") return commandEnvShow(ctx); + if (sub === "doctor") return await commandEnvDoctor(ctx); + } + + if (command === "fixture") { + const sub = ctx.args[1]; + if (sub === "list") return commandFixtureList(ctx); + if (sub === "check") return commandFixtureCheck(ctx); + } + + if (command === "log") { + const sub = ctx.args[1]; + if (sub === "scan") return commandLogScan(ctx); + if (sub === "watch") return await commandLogWatch(ctx); + if (sub === "guard") return commandLogGuard(ctx); + } + + if (command === "case") { + const sub = ctx.args[1]; + if (sub === "new") return commandCaseNew(ctx); + if (sub === "list") return commandCaseList(ctx); + if (sub === "show") return commandCaseShow(ctx); + } + + if (command === "suite") { + const sub = ctx.args[1]; + if (sub === "new") return commandSuiteNew(ctx); + if (sub === "list") return commandSuiteList(ctx); + if (sub === "show") return commandSuiteShow(ctx); + if (sub === "plan") return commandSuitePlan(ctx); + if (sub === "start") return commandSuiteStart(ctx); + if (sub === "run") return commandSuiteRun(ctx); + if (sub === "report") return commandSuiteReport(ctx); + } + + if (command === "test") { + const sub = ctx.args[1]; + if (sub === "plan") return commandTestPlan(ctx); + if (sub === "recommend") return commandTestRecommend(ctx); + if (sub === "start") return commandTestStart(ctx); + if (sub === "run") return commandTestRun(ctx); + if (sub === "report") return commandTestReport(ctx); + if (sub === "result") return commandTestResult(ctx); + } + + if (command === "trouble") { + const sub = ctx.args[1]; + if (sub === "list") return commandTroubleList(ctx); + if (sub === "show") return commandTroubleShow(ctx); + if (sub === "search") return commandTroubleSearch(ctx); + if (sub === "add") return commandTroubleAdd(ctx); + } + + usage(); +} + +exit(await main()); diff --git a/skills/src/log-guard.ts b/skills/src/log-guard.ts new file mode 100644 index 0000000..6f7f541 --- /dev/null +++ b/skills/src/log-guard.ts @@ -0,0 +1,825 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import type { StructuredItem } from "./types.ts"; +import { listValue, loadEnv, loadStructuredItems, scalar } from "./fs.ts"; + +export type LogSourceName = "backend" | "frontend" | "console"; +export type FindingSeverity = + | "fail" + | "warning" + | "matched_troubleshooting" + | "env_issue" + | "ignored_expected_issue" + | "missing_input"; + +export type LogFinding = { + source: LogSourceName; + path: string; + severity: FindingSeverity; + kind: string; + pattern: string; + line?: number; + excerpt?: string; + troubleshooting_id?: string; + troubleshooting_title?: string; + related_to_case?: boolean; +}; + +export type LogLine = { + number: number; + text: string; +}; + +export type LogSuccessSignal = { + source: LogSourceName; + path: string; + kind: "case_success_pattern"; + pattern: string; + line?: number; + excerpt?: string; +}; + +export type LogScanMode = + | "whole-file" + | "since" + | "until" + | "since+until" + | "tail-lines" + | "since+tail-lines" + | "until+tail-lines" + | "since+until+tail-lines"; + +export type LogScanConfig = { + mode: LogScanMode; + since?: string; + since_epoch_ms?: number; + until?: string; + until_epoch_ms?: number; + tail_lines?: number; + warnings: string[]; +}; + +export type LogSourceSummary = { + source: LogSourceName; + path: string; + status: "scanned" | "missing" | "auto_not_found"; + line_count: number; + total_line_count?: number; + start_line?: number; + end_line?: number; + timestamped_line_count?: number; + auto_detected?: boolean; +}; + +export type LogGuardPatternContext = { + successPatterns?: string[]; + failurePatterns?: string[]; + expectedFailures?: string[]; + relatedTroubleshootingIds?: string[]; +}; + +export type LogGuardResult = { + status: string; + scan: LogScanConfig; + sources: LogSourceSummary[]; + success_signals: LogSuccessSignal[]; + findings: LogFinding[]; +}; + +export type AutomationResultEvidence = { + status: "not_provided" | "missing" | "invalid" | "loaded"; + path?: string; + result?: string; + reason?: string; + duration_ms?: number; + started_at?: string; + started_at_local?: string; + finished_at?: string; + finished_at_local?: string; + url?: string; + prompt?: string; + expected_text?: string; + metrics_summary?: Record; + thresholds_summary?: Record; + artifacts?: Record; +}; + +type MutableScanState = { + findings: LogFinding[]; + successSignals: LogSuccessSignal[]; + seenFindings: Set; + seenSuccessSignals: Set; +}; + +const secretAssignmentRe = /\b(api[_-]?key|authorization|credential|jwt|oauth|password|secret|token)\s*[:=]\s*["']?([^"',\s]+)/gi; +const bearerSecretRe = /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/i; +const openAiStyleSecretRe = /\bsk-[A-Za-z0-9_-]{6,}\b/i; + +const unexpectedPatterns: Array<{ + kind: string; + pattern: string; + regex: RegExp; + severity: FindingSeverity; + sources?: LogSourceName[]; +}> = [ + { kind: "python_traceback", pattern: "Traceback", regex: /\bTraceback(?: \(most recent call last\))?/i, severity: "fail" }, + { + kind: "unretrieved_task_exception", + pattern: "Task exception was never retrieved", + regex: /Task exception was never retrieved/i, + severity: "fail", + }, + { + kind: "unawaited_coroutine", + pattern: "RuntimeWarning: coroutine .* was never awaited", + regex: /RuntimeWarning:\s+coroutine .* was never awaited/i, + severity: "fail", + }, + { + kind: "unclosed_client_session", + pattern: "Unclosed client session", + regex: /Unclosed client session/i, + severity: "fail", + }, + { kind: "unclosed_connector", pattern: "Unclosed connector", regex: /Unclosed connector/i, severity: "fail" }, + { kind: "key_error", pattern: "KeyError", regex: /(^|[^A-Za-z])KeyError(?:\b|:)/, severity: "fail" }, + { kind: "type_error", pattern: "TypeError", regex: /(^|[^A-Za-z])TypeError(?:\b|:)/, severity: "fail" }, + { + kind: "attribute_error", + pattern: "AttributeError", + regex: /(^|[^A-Za-z])AttributeError(?:\b|:)/, + severity: "fail", + }, + { + kind: "frontend_uncaught_error", + pattern: "Uncaught frontend error", + regex: /\bUncaught (?:[A-Za-z]*Error|Exception)|Unhandled(?: promise rejection|Rejection)/i, + severity: "fail", + sources: ["console", "frontend"], + }, + { + kind: "http_5xx", + pattern: "HTTP 5xx resource failure", + regex: /Failed to load resource: the server responded with a status of 5\d\d|HTTP\/\d(?:\.\d)?\s+5\d\d/i, + severity: "fail", + }, + { kind: "error_log", pattern: "ERROR or CRITICAL log line", regex: /\b(?:ERROR|CRITICAL)\b/, severity: "warning" }, +]; + +export function logPatternContextFromStructuredItem(item: StructuredItem): LogGuardPatternContext { + return { + successPatterns: listValue(item.fields, "success_patterns"), + failurePatterns: listValue(item.fields, "failure_patterns"), + expectedFailures: listValue(item.fields, "expected_failures"), + relatedTroubleshootingIds: listValue(item.fields, "troubleshooting"), + }; +} + +export function scanStructuredLogSources( + root: string, + item: StructuredItem, + options: Record, +): LogGuardResult { + return scanLogSources(root, options, logPatternContextFromStructuredItem(item)); +} + +export function scanLogSources( + root: string, + options: Record, + context: LogGuardPatternContext = {}, +): LogGuardResult { + const env = loadEnv(root); + const scan = parseScanConfig(optionsWithEvidenceWindow(options)); + const configuredSources: Array<{ source: LogSourceName; option: string }> = [ + { source: "backend", option: "backend-log" }, + { source: "frontend", option: "frontend-log" }, + { source: "console", option: "console-log" }, + ]; + const sources: LogSourceSummary[] = []; + const state: MutableScanState = { + findings: [], + successSignals: [], + seenFindings: new Set(), + seenSuccessSignals: new Set(), + }; + + for (const warning of scan.warnings) { + addFinding(state, { + source: "backend", + path: "log-scan-options", + severity: "missing_input", + kind: "invalid_log_scan_option", + pattern: warning, + }); + } + + for (const configured of configuredSources) { + const explicitPath = options[configured.option]; + const autoPath = configured.source === "backend" && options["no-auto-log"] !== true + ? latestLangBotLogPath(env) + : null; + const rawPath = typeof explicitPath === "string" ? explicitPath : autoPath; + const autoDetected = typeof explicitPath !== "string" && rawPath === autoPath; + if (!rawPath) { + if (configured.source === "backend" && options["no-auto-log"] !== true) { + const logsDir = env.LANGBOT_REPO ? join(env.LANGBOT_REPO, "data", "logs") : "LANGBOT_REPO/data/logs"; + sources.push({ source: "backend", path: join(logsDir, "langbot-*.log"), status: "auto_not_found", line_count: 0, auto_detected: true }); + } + continue; + } + + const path = resolve(rawPath); + if (!existsSync(path)) { + sources.push({ source: configured.source, path, status: "missing", line_count: 0 }); + if (!autoDetected) { + addFinding(state, { + source: configured.source, + path, + severity: "missing_input", + kind: "missing_log_file", + pattern: `${configured.option} path does not exist`, + }); + } + continue; + } + + const text = readFileSync(path, "utf8"); + scanLogTextIntoState(root, configured.source, path, text, scan, context, sources, state); + } + + finalizeMissingSuccessSignal(context, sources, state); + return buildLogGuardResult(scan, sources, state); +} + +export function scanLogText( + root: string, + source: LogSourceName, + path: string, + text: string, + options: Record = {}, + context: LogGuardPatternContext = {}, + baseLineNumber = 0, + includeMissingSuccessSignal = true, +): LogGuardResult { + const scan = parseScanConfig(options); + const sources: LogSourceSummary[] = []; + const state: MutableScanState = { + findings: [], + successSignals: [], + seenFindings: new Set(), + seenSuccessSignals: new Set(), + }; + + scanLogTextIntoState(root, source, resolve(path), text, scan, context, sources, state, baseLineNumber); + if (includeMissingSuccessSignal) finalizeMissingSuccessSignal(context, sources, state); + return buildLogGuardResult(scan, sources, state); +} + +function scanLogTextIntoState( + root: string, + source: LogSourceName, + path: string, + text: string, + scan: LogScanConfig, + context: LogGuardPatternContext, + sources: LogSourceSummary[], + state: MutableScanState, + baseLineNumber = 0, +): void { + const allLines = text.split(/\r?\n/).map((line, index) => ({ number: baseLineNumber + index + 1, text: line })); + const selected = selectLinesForScan(allLines, scan); + sources.push({ + source, + path, + status: "scanned", + line_count: selected.lines.length, + total_line_count: allLines.length, + start_line: selected.lines[0]?.number, + end_line: selected.lines[selected.lines.length - 1]?.number, + timestamped_line_count: selected.timestampedLineCount, + }); + + scanUnexpectedPatterns(state, source, path, selected.lines, context.expectedFailures ?? []); + scanCaseDeclaredPatterns( + state, + source, + path, + selected.lines, + context.successPatterns ?? [], + context.failurePatterns ?? [], + context.expectedFailures ?? [], + ); + scanTroubleshootingPatterns( + state, + source, + path, + selected.lines, + loadStructuredItems(root, "troubleshooting"), + new Set(context.relatedTroubleshootingIds ?? []), + context.expectedFailures ?? [], + ); +} + +function buildLogGuardResult(scan: LogScanConfig, sources: LogSourceSummary[], state: MutableScanState): LogGuardResult { + const scannedCount = sources.filter((source) => source.status === "scanned").length; + const status = scannedCount === 0 && state.findings.length === 0 + ? "not_run" + : state.findings.some((finding) => finding.severity === "fail" || finding.severity === "missing_input") + ? "fail" + : state.findings.some((finding) => finding.severity === "matched_troubleshooting" && finding.related_to_case !== false) + ? "fail" + : state.findings.some((finding) => finding.severity === "env_issue") + ? "env_issue" + : state.findings.some((finding) => finding.severity === "warning") + ? "warning" + : "pass"; + + return { status, scan, sources, success_signals: state.successSignals, findings: state.findings }; +} + +function finalizeMissingSuccessSignal( + context: LogGuardPatternContext, + sources: LogSourceSummary[], + state: MutableScanState, +): void { + const scannedCount = sources.filter((source) => source.status === "scanned").length; + const successPatterns = context.successPatterns ?? []; + if (scannedCount > 0 && successPatterns.length > 0 && state.successSignals.length === 0) { + addFinding(state, { + source: "backend", + path: "case-success-patterns", + severity: "warning", + kind: "missing_success_signal", + pattern: successPatterns.join(" | "), + excerpt: "No declared success_patterns matched the scanned log window.", + }); + } +} + +function shouldTreatAssignmentValueAsSecret(value: string): boolean { + const normalized = value.trim().replace(/^["']|["']$/g, ""); + const lower = normalized.toLowerCase(); + if (!normalized) return false; + if (["error", "invalid", "missing", "none", "null", "undefined", "redacted", "[redacted]"].includes(lower)) { + return false; + } + if (/^(error|invalid|missing|none|null|undefined)\b/i.test(normalized)) return false; + if (/^(your-|<|\$\{|example-|placeholder)/i.test(normalized)) return false; + if (openAiStyleSecretRe.test(normalized)) return true; + return normalized.length >= 8 && /[A-Za-z0-9]/.test(normalized); +} + +function redactSecretAssignments(text: string): string { + return text.replace(secretAssignmentRe, (match, key: string, value: string) => { + if (!shouldTreatAssignmentValueAsSecret(value)) return match; + return match.replace(value, "[redacted]"); + }); +} + +export function redactSecrets(text: string): string { + return redactSecretAssignments(text + .replace(/(\bauthorization\s*[:=]\s*bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]") + .replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]") + .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]")); +} + +function hasSecretLeak(line: string): boolean { + secretAssignmentRe.lastIndex = 0; + const hasSecretAssignment = Array.from(line.matchAll(secretAssignmentRe)) + .some((match) => shouldTreatAssignmentValueAsSecret(match[2] ?? "")); + return hasSecretAssignment || bearerSecretRe.test(line) || openAiStyleSecretRe.test(line); +} + +function findingKey(finding: LogFinding): string { + return [ + finding.source, + finding.path, + finding.kind, + finding.pattern, + finding.line ?? "", + finding.troubleshooting_id ?? "", + ].join("\0"); +} + +function addFinding(state: MutableScanState, finding: LogFinding): void { + const key = findingKey(finding); + if (state.seenFindings.has(key)) return; + state.seenFindings.add(key); + state.findings.push(finding); +} + +function successSignalKey(signal: LogSuccessSignal): string { + return [signal.source, signal.path, signal.pattern, signal.line ?? ""].join("\0"); +} + +function addSuccessSignal(state: MutableScanState, signal: LogSuccessSignal): void { + const key = successSignalKey(signal); + if (state.seenSuccessSignals.has(key)) return; + state.seenSuccessSignals.add(key); + state.successSignals.push(signal); +} + +function isExpectedFinding(finding: LogFinding, expectedFailures: string[]): boolean { + if (finding.kind === "secret_leak" || finding.severity === "missing_input") return false; + const haystack = [ + finding.kind, + finding.pattern, + finding.troubleshooting_id ?? "", + finding.troubleshooting_title ?? "", + finding.excerpt ?? "", + ].join("\n").toLowerCase(); + return expectedFailures.some((item) => item && haystack.includes(item.toLowerCase())); +} + +function withExpectedSeverity(finding: LogFinding, expectedFailures: string[]): LogFinding { + if (!isExpectedFinding(finding, expectedFailures)) return finding; + return { ...finding, severity: "ignored_expected_issue" }; +} + +function scanUnexpectedPatterns( + state: MutableScanState, + source: LogSourceName, + path: string, + lines: LogLine[], + expectedFailures: string[], +): void { + for (const line of lines) { + for (const pattern of unexpectedPatterns) { + if (pattern.sources && !pattern.sources.includes(source)) continue; + if (!pattern.regex.test(line.text)) continue; + addFinding(state, withExpectedSeverity({ + source, + path, + severity: pattern.severity, + kind: pattern.kind, + pattern: pattern.pattern, + line: line.number, + excerpt: redactSecrets(line.text.trim()), + }, expectedFailures)); + } + + if (hasSecretLeak(line.text)) { + addFinding(state, { + source, + path, + severity: "fail", + kind: "secret_leak", + pattern: "secret-like value in logs", + line: line.number, + excerpt: redactSecrets(line.text.trim()), + }); + } + } +} + +function scanTroubleshootingPatterns( + state: MutableScanState, + source: LogSourceName, + path: string, + lines: LogLine[], + troubles: StructuredItem[], + relatedIds: Set, + expectedFailures: string[], +): void { + for (const entry of troubles) { + const id = scalar(entry.fields, "id"); + const title = scalar(entry.fields, "title"); + const category = scalar(entry.fields, "category"); + for (const pattern of listValue(entry.fields, "patterns")) { + const needle = pattern.toLowerCase(); + if (!needle) continue; + let matchesForPattern = 0; + for (const line of lines) { + if (!line.text.toLowerCase().includes(needle)) continue; + if (id === "plugin-runtime-timeout" && isModelRouteUnavailableText(line.text)) continue; + addFinding(state, withExpectedSeverity({ + source, + path, + severity: category === "env_issue" ? "env_issue" : "matched_troubleshooting", + kind: "troubleshooting_pattern", + pattern, + line: line.number, + excerpt: redactSecrets(line.text.trim()), + troubleshooting_id: id, + troubleshooting_title: title, + related_to_case: relatedIds.has(id), + }, expectedFailures)); + matchesForPattern += 1; + if (matchesForPattern >= 3) break; + } + } + } +} + +function isModelRouteUnavailableText(text: string): boolean { + return /model_not_found|no available channel for model|invalid api key|当前分组上游负载已饱和/i.test(text); +} + +function scanCaseDeclaredPatterns( + state: MutableScanState, + source: LogSourceName, + path: string, + lines: LogLine[], + successPatterns: string[], + failurePatterns: string[], + expectedFailures: string[], +): void { + for (const pattern of successPatterns) { + const needle = pattern.toLowerCase(); + if (!needle) continue; + let matchesForPattern = 0; + for (const line of lines) { + if (!line.text.toLowerCase().includes(needle)) continue; + addSuccessSignal(state, { + source, + path, + kind: "case_success_pattern", + pattern, + line: line.number, + excerpt: redactSecrets(line.text.trim()), + }); + matchesForPattern += 1; + if (matchesForPattern >= 3) break; + } + } + + for (const pattern of failurePatterns) { + const needle = pattern.toLowerCase(); + if (!needle) continue; + let matchesForPattern = 0; + for (const line of lines) { + if (!line.text.toLowerCase().includes(needle)) continue; + addFinding(state, withExpectedSeverity({ + source, + path, + severity: "fail", + kind: "case_failure_pattern", + pattern, + line: line.number, + excerpt: redactSecrets(line.text.trim()), + }, expectedFailures)); + matchesForPattern += 1; + if (matchesForPattern >= 3) break; + } + } +} + +function optionsWithEvidenceWindow(options: Record): Record { + if (typeof options.since === "string" && typeof options.until === "string") { + return options; + } + + const evidenceDir = evidenceDirFromOptions(options); + if (!evidenceDir) return options; + + const resultPath = automationResultPath(evidenceDir); + if (!existsSync(resultPath)) return options; + + try { + const result = JSON.parse(readFileSync(resultPath, "utf8")) as Record; + const enriched = { ...options }; + const startedAt = stringField(result, "started_at_local") ?? stringField(result, "started_at"); + const finishedAt = stringField(result, "finished_at_local") ?? stringField(result, "finished_at"); + + if (typeof enriched.since !== "string" && startedAt) { + enriched.since = startedAt; + } + if (typeof enriched.until !== "string" && finishedAt) { + enriched.until = finishedAt; + } + return enriched; + } catch { + return options; + } +} + +function stringField(data: Record, key: string): string | undefined { + const value = data[key]; + return typeof value === "string" && value.trim() ? value : undefined; +} + +function numberField(data: Record, key: string): number | undefined { + const value = data[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function objectField(data: Record, key: string): Record | undefined { + const value = data[key]; + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function evidenceDirFromOptions(options: Record): string | undefined { + const explicit = typeof options["evidence-dir"] === "string" ? options["evidence-dir"] : undefined; + if (explicit) return resolve(explicit); + const consoleLog = typeof options["console-log"] === "string" ? options["console-log"] : undefined; + return consoleLog ? dirname(resolve(consoleLog)) : undefined; +} + +function automationResultPath(evidenceDir: string): string { + const primary = join(evidenceDir, "automation-result.json"); + if (existsSync(primary)) return primary; + return join(evidenceDir, "result.json"); +} + +export function readAutomationResultEvidence(options: Record): AutomationResultEvidence { + const evidenceDir = evidenceDirFromOptions(options); + if (!evidenceDir) return { status: "not_provided" }; + + const resultPath = automationResultPath(evidenceDir); + if (!existsSync(resultPath)) return { status: "missing", path: resultPath }; + + try { + const result = JSON.parse(readFileSync(resultPath, "utf8")) as Record; + if (result.source === "final") { + return { + status: "not_provided", + path: resultPath, + reason: "only final result.json is present; automation-result.json was not found", + }; + } + return { + status: "loaded", + path: resultPath, + result: stringField(result, "status"), + reason: stringField(result, "reason"), + duration_ms: numberField(result, "duration_ms"), + started_at: stringField(result, "started_at"), + started_at_local: stringField(result, "started_at_local"), + finished_at: stringField(result, "finished_at"), + finished_at_local: stringField(result, "finished_at_local"), + url: stringField(result, "url"), + prompt: redactSecrets(stringField(result, "prompt") ?? ""), + expected_text: stringField(result, "expected_text"), + metrics_summary: objectField(result, "metrics_summary"), + thresholds_summary: objectField(result, "thresholds_summary"), + artifacts: objectField(result, "artifacts"), + }; + } catch (error) { + return { status: "invalid", path: resultPath, reason: String(error) }; + } +} + +export function latestLangBotLogPath(env: Record): string | null { + const repo = env.LANGBOT_REPO; + if (!repo) return null; + const logsDir = join(repo, "data", "logs"); + if (!existsSync(logsDir)) return null; + + const candidates = readdirSync(logsDir) + .filter((name) => /^langbot-.*\.log$/.test(name)) + .map((name) => join(logsDir, name)) + .filter((path) => { + try { + return statSync(path).isFile(); + } catch { + return false; + } + }) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); + + return candidates[0] ?? null; +} + +export function parseScanConfig(options: Record): LogScanConfig { + const warnings: string[] = []; + const sinceInput = typeof options.since === "string" ? options.since : undefined; + const sinceMs = sinceInput ? Date.parse(sinceInput) : undefined; + const untilInput = typeof options.until === "string" ? options.until : undefined; + const untilMs = untilInput ? Date.parse(untilInput) : undefined; + const tailInput = typeof options["tail-lines"] === "string" ? options["tail-lines"] : undefined; + let tailLines: number | undefined; + + if (sinceInput && Number.isNaN(sinceMs)) { + warnings.push(`--since is not a valid date/time: ${sinceInput}`); + } + if (untilInput && Number.isNaN(untilMs)) { + warnings.push(`--until is not a valid date/time: ${untilInput}`); + } + + if (tailInput) { + const parsed = Number.parseInt(tailInput, 10); + if (!/^\d+$/.test(tailInput) || parsed <= 0) { + warnings.push(`--tail-lines must be a positive integer: ${tailInput}`); + } else { + tailLines = parsed; + } + } + + const hasSince = sinceInput !== undefined && sinceMs !== undefined && !Number.isNaN(sinceMs); + const hasUntil = untilInput !== undefined && untilMs !== undefined && !Number.isNaN(untilMs); + const hasTail = tailLines !== undefined; + let mode: LogScanMode = "whole-file"; + if (hasSince && hasUntil && hasTail) { + mode = "since+until+tail-lines"; + } else if (hasSince && hasUntil) { + mode = "since+until"; + } else if (hasSince && hasTail) { + mode = "since+tail-lines"; + } else if (hasUntil && hasTail) { + mode = "until+tail-lines"; + } else if (hasSince) { + mode = "since"; + } else if (hasUntil) { + mode = "until"; + } else if (hasTail) { + mode = "tail-lines"; + } + + return { + mode, + since: hasSince ? sinceInput : undefined, + since_epoch_ms: hasSince ? sinceMs : undefined, + until: hasUntil ? untilInput : undefined, + until_epoch_ms: hasUntil ? untilMs : undefined, + tail_lines: tailLines, + warnings, + }; +} + +function selectLinesForScan(lines: LogLine[], scan: LogScanConfig): { lines: LogLine[]; timestampedLineCount: number } { + let selected = lines; + let timestampedLineCount = 0; + + if (scan.since_epoch_ms !== undefined || scan.until_epoch_ms !== undefined) { + const offsetMinutes = + timezoneOffsetMinutes(scan.since) + ?? timezoneOffsetMinutes(scan.until) + ?? -new Date(scan.since_epoch_ms ?? scan.until_epoch_ms ?? Date.now()).getTimezoneOffset(); + const yearHint = new Date(scan.since_epoch_ms ?? scan.until_epoch_ms ?? Date.now()).getUTCFullYear(); + let includeCurrentBlock = false; + const filtered = lines.filter((line) => { + const timestamp = parseLogLineTimestampMs(line.text, yearHint, offsetMinutes); + if (timestamp !== null) { + timestampedLineCount += 1; + includeCurrentBlock = + (scan.since_epoch_ms === undefined || timestamp >= scan.since_epoch_ms) + && (scan.until_epoch_ms === undefined || timestamp <= scan.until_epoch_ms); + return includeCurrentBlock; + } + return includeCurrentBlock; + }); + selected = timestampedLineCount === 0 ? lines : filtered; + } else { + timestampedLineCount = lines.reduce((count, line) => ( + parseLogLineTimestampMs(line.text, new Date().getFullYear(), -new Date().getTimezoneOffset()) === null + ? count + : count + 1 + ), 0); + } + + if (scan.tail_lines !== undefined && selected.length > scan.tail_lines) { + selected = selected.slice(-scan.tail_lines); + } + + return { lines: selected, timestampedLineCount }; +} + +function timezoneOffsetMinutes(input: string | undefined): number | null { + if (!input) return null; + if (/[zZ]$/.test(input)) return 0; + const match = input.match(/([+-])(\d{2}):?(\d{2})$/); + if (!match) return null; + const sign = match[1] === "-" ? -1 : 1; + return sign * (Number.parseInt(match[2], 10) * 60 + Number.parseInt(match[3], 10)); +} + +function parseLogLineTimestampMs(line: string, yearHint: number, offsetMinutes: number): number | null { + const fullIso = line.match(/^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?)\]?/); + if (fullIso) { + const timestamp = Date.parse(fullIso[1].replace(" ", "T")); + return Number.isNaN(timestamp) ? null : timestamp; + } + + const langBot = line.match(/^\[(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})\]/); + if (!langBot) return null; + const [, month, day, hour, minute, second, millisecond] = langBot; + return Date.UTC( + yearHint, + Number.parseInt(month, 10) - 1, + Number.parseInt(day, 10), + Number.parseInt(hour, 10), + Number.parseInt(minute, 10), + Number.parseInt(second, 10), + Number.parseInt(millisecond, 10), + ) - offsetMinutes * 60 * 1000; +} + +export function renderLogFinding(finding: LogFinding): string { + const location = finding.line ? `${finding.source}:${finding.line}` : finding.source; + const trouble = finding.troubleshooting_id ? ` (${finding.troubleshooting_id})` : ""; + const related = finding.related_to_case === true ? ", related" : ""; + const excerpt = finding.excerpt ? ` - ${finding.excerpt}` : ""; + return `- [${finding.severity}] ${location}: ${finding.kind}${trouble}${related}; pattern: ${finding.pattern}${excerpt}`; +} + +export function renderLogSuccessSignal(signal: LogSuccessSignal): string { + const location = signal.line ? `${signal.source}:${signal.line}` : signal.source; + const excerpt = signal.excerpt ? ` - ${signal.excerpt}` : ""; + return `- ${location}: ${signal.pattern}${excerpt}`; +} + +export function strictLogGuardExitCode(result: LogGuardResult): number { + return result.status === "fail" || result.status === "env_issue" ? 1 : 0; +} diff --git a/skills/src/readiness.ts b/skills/src/readiness.ts new file mode 100644 index 0000000..945fcb5 --- /dev/null +++ b/skills/src/readiness.ts @@ -0,0 +1,277 @@ +import { env as processEnv } from "node:process"; +import type { StructuredItem } from "./types.ts"; +import { loadFixtureItems } from "./fixtures.ts"; +import { listValue, loadEnv, scalar } from "./fs.ts"; +import { splitEnvAnyGroup } from "./env-groups.ts"; + +type EnvSource = Record; + +export type EnvReadiness = { + status: "ready" | "missing" | "not_required"; + required: string[]; + configured: string[]; + missing: string[]; + values: Record; +}; + +export type AutomationReadiness = EnvReadiness & { + script: string; + defaulted: string[]; + pipeline_env_required: boolean; + env_aliases: Array<{ + target: string; + source: string; + configured: boolean; + }>; +}; + +export type ManualReadiness = { + status: "manual_check" | "not_required"; + preconditions: string[]; + setup: string[]; + cleanup: string[]; +}; + +export type FixtureReadiness = { + status: "ready" | "missing" | "not_required"; + required: Array<{ + id: string; + kind: string; + path: string; + exists: boolean; + }>; + missing: string[]; +}; + +const secretKeyRe = /(?:api[_-]?key|authorization|bearer|credential|jwt|oauth|password|secret|token)/i; + +export function redactEnvValue(key: string, value: string): string { + if (!value) return ""; + if (secretKeyRe.test(key)) return "[redacted]"; + return value.replace(/(https?:\/\/)([^:@/\s]+):([^@/\s]+)@/i, "$1[redacted]@"); +} + +export function runtimeEnv(root: string): Record { + const result: Record = { ...loadEnv(root) }; + for (const [key, value] of Object.entries(processEnv)) { + if (typeof value === "string") result[key] = value; + } + return result; +} + +function envReadiness( + keys: string[], + env: EnvSource, + defaults: Record = {}, + anyGroups: string[] = [], + providedBySetup: Set = new Set(), +): EnvReadiness { + const required = [...keys]; + const configured = required.filter((key) => Boolean(env[key]) || Boolean(defaults[key]) || providedBySetup.has(key)); + const missing = required.filter((key) => !env[key] && !defaults[key] && !providedBySetup.has(key)); + const values: Record = Object.fromEntries( + required.map((key) => [key, redactEnvValue(key, env[key] ?? defaults[key] ?? setupProvidedValue(key, providedBySetup))]), + ); + + for (const group of anyGroups) { + const keysInGroup = splitEnvAnyGroup(group); + required.push(group); + const configuredKeys = keysInGroup.filter((key) => Boolean(env[key]) || Boolean(defaults[key]) || providedBySetup.has(key)); + if (configuredKeys.length === 0) missing.push(group); + else configured.push(...configuredKeys); + for (const key of keysInGroup) { + values[key] = redactEnvValue(key, env[key] ?? defaults[key] ?? setupProvidedValue(key, providedBySetup)); + } + } + + return { + status: required.length === 0 ? "not_required" : missing.length === 0 ? "ready" : "missing", + required, + configured: Array.from(new Set(configured)), + missing, + values, + }; +} + +function setupProvidedValue(key: string, providedBySetup: Set): string { + return providedBySetup.has(key) ? "[provided by setup_automation]" : ""; +} + +export function setupProvidedEnv(item: StructuredItem): Set { + return new Set(listValue(item.fields, "setup_provides_env")); +} + +export function automationEnvDefaults(item: StructuredItem, env: EnvSource = processEnv): Record { + const mapping: Array<[string, string]> = [ + ["automation_prompt", "LANGBOT_E2E_PROMPT"], + ["automation_prompts_json", "LANGBOT_E2E_PROMPTS_JSON"], + ["automation_expected_text", "LANGBOT_E2E_EXPECTED_TEXT"], + ["automation_response_timeout_ms", "LANGBOT_E2E_RESPONSE_TIMEOUT_MS"], + ["automation_stream_output", "LANGBOT_E2E_STREAM_OUTPUT"], + ["automation_image_base64_fixture", "LANGBOT_E2E_IMAGE_BASE64_PATH"], + ["automation_runner_config_patch_json", "LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON"], + ["automation_restore_runner_config", "LANGBOT_E2E_RESTORE_RUNNER_CONFIG"], + ["automation_expected_runner_id", "LANGBOT_E2E_EXPECTED_RUNNER_ID"], + ["automation_reset_debug_chat", "LANGBOT_E2E_RESET_DEBUG_CHAT"], + ["automation_debug_chat_session_type", "LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE"], + ["automation_debug_chat_response_p95_ms", "LANGBOT_E2E_DEBUG_CHAT_RESPONSE_P95_MS"], + ["automation_debug_chat_max_error_rate", "LANGBOT_E2E_DEBUG_CHAT_MAX_ERROR_RATE"], + ["automation_debug_chat_load_requests", "LANGBOT_DEBUG_CHAT_LOAD_REQUESTS"], + ["automation_debug_chat_load_concurrency", "LANGBOT_DEBUG_CHAT_LOAD_CONCURRENCY"], + ["automation_debug_chat_load_timeout_ms", "LANGBOT_DEBUG_CHAT_LOAD_TIMEOUT_MS"], + ["automation_debug_chat_load_response_p95_ms", "LANGBOT_DEBUG_CHAT_LOAD_RESPONSE_P95_MS"], + ["automation_debug_chat_load_first_response_p95_ms", "LANGBOT_DEBUG_CHAT_LOAD_FIRST_RESPONSE_P95_MS"], + ["automation_debug_chat_load_max_error_rate", "LANGBOT_DEBUG_CHAT_LOAD_MAX_ERROR_RATE"], + ["automation_debug_chat_load_min_error_rate", "LANGBOT_DEBUG_CHAT_LOAD_MIN_ERROR_RATE"], + ["automation_debug_chat_load_min_error_count", "LANGBOT_DEBUG_CHAT_LOAD_MIN_ERROR_COUNT"], + ["automation_debug_chat_load_min_ok_count", "LANGBOT_DEBUG_CHAT_LOAD_MIN_OK_COUNT"], + ["automation_debug_chat_load_min_provider_fault_count", "LANGBOT_DEBUG_CHAT_LOAD_MIN_PROVIDER_FAULT_COUNT"], + ["automation_debug_chat_load_expected_prefix", "LANGBOT_DEBUG_CHAT_LOAD_EXPECTED_PREFIX"], + ["automation_debug_chat_load_prompt_template", "LANGBOT_DEBUG_CHAT_LOAD_PROMPT_TEMPLATE"], + ["automation_debug_chat_load_stream", "LANGBOT_DEBUG_CHAT_LOAD_STREAM"], + ["automation_debug_chat_load_reset", "LANGBOT_DEBUG_CHAT_LOAD_RESET"], + ["automation_debug_chat_load_fail_on_final_mismatch", "LANGBOT_DEBUG_CHAT_LOAD_FAIL_ON_FINAL_MISMATCH"], + ["automation_fake_provider_response_text", "LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT"], + ["automation_fake_provider_first_token_delay_ms", "LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS"], + ["automation_fake_provider_chunk_delay_ms", "LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS"], + ["automation_fake_provider_chunk_count", "LANGBOT_FAKE_PROVIDER_CHUNK_COUNT"], + ["automation_fake_provider_fail_first_n", "LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N"], + ["automation_fake_provider_fail_every_n", "LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N"], + ["automation_fake_provider_fault_status", "LANGBOT_FAKE_PROVIDER_FAULT_STATUS"], + ["automation_fake_provider_fail_after_first_chunk", "LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK"], + ["automation_fake_provider_dynamic_response", "LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE"], + ["automation_filesystem_checks_json", "LANGBOT_E2E_FILESYSTEM_CHECKS_JSON"], + ["automation_plugin_package", "LANGBOT_E2E_PLUGIN_PACKAGE"], + ["automation_expected_plugin_id", "LANGBOT_E2E_EXPECTED_PLUGIN_ID"], + ["automation_expected_tool", "LANGBOT_E2E_EXPECTED_TOOL"], + ]; + const defaults: Record = {}; + for (const [field, envKey] of mapping) { + const value = scalar(item.fields, field); + if (value) defaults[envKey] = expandEnvRefs(value, env); + } + const failurePatterns = listValue(item.fields, "failure_patterns"); + if (failurePatterns.length > 0) defaults.LANGBOT_E2E_FAILURE_SIGNALS = failurePatterns.join("\n"); + return defaults; +} + +function expandEnvRefs(value: string, env: EnvSource): string { + return value.replace(/\$\{([A-Z][A-Z0-9_]*)\}|\$([A-Z][A-Z0-9_]*)/g, (_match, braced, bare) => { + return env[braced || bare] || ""; + }); +} + +export function caseEnvReadiness(item: StructuredItem, env: EnvSource): EnvReadiness { + const aliasSources = new Set(automationEnvAliases(item, env).map((alias) => alias.source)); + const provided = setupProvidedEnv(item); + return envReadiness( + listValue(item.fields, "env").filter((key) => !aliasSources.has(key)), + env, + {}, + listValue(item.fields, "env_any"), + provided, + ); +} + +function automationEnvAliases(item: StructuredItem, env: EnvSource): Array<{ + target: string; + source: string; + configured: boolean; +}> { + const provided = setupProvidedEnv(item); + const mapping: Array<[string, string]> = [ + ["automation_pipeline_url_env", "LANGBOT_E2E_PIPELINE_URL"], + ["automation_pipeline_name_env", "LANGBOT_E2E_PIPELINE_NAME"], + ]; + return mapping + .map(([field, target]) => { + const source = scalar(item.fields, field); + return source ? { target, source, configured: Boolean(env[source]) || provided.has(source) } : null; + }) + .filter((item): item is { target: string; source: string; configured: boolean } => item !== null); +} + +export function automationPipelineEnvRequired(item: StructuredItem): boolean { + return Boolean(scalar(item.fields, "automation_pipeline_url_env") || scalar(item.fields, "automation_pipeline_name_env")); +} + +export function caseAutomationReadiness(item: StructuredItem, env: EnvSource): AutomationReadiness { + const script = scalar(item.fields, "automation"); + const aliases = automationEnvAliases(item, env); + const aliasSources = new Set(aliases.map((alias) => alias.source)); + const defaults = automationEnvDefaults(item, env); + const provided = setupProvidedEnv(item); + const requiredKeys = listValue(item.fields, "automation_env").filter((key) => !aliasSources.has(key)); + const readiness = envReadiness(requiredKeys, env, defaults, listValue(item.fields, "automation_env_any"), provided); + const defaulted = requiredKeys.filter((key) => !env[key] && Boolean(defaults[key])); + const aliasConfigured = aliases.some((alias) => alias.configured); + const aliasMissing = automationPipelineEnvRequired(item) && !aliasConfigured + ? [aliases.map((alias) => alias.source).join("|")] + : []; + const missing = [...readiness.missing, ...aliasMissing].filter(Boolean); + const configured = [ + ...readiness.configured, + ...aliases.filter((alias) => alias.configured).map((alias) => alias.source), + ]; + const values = { + ...readiness.values, + ...Object.fromEntries(aliases.map((alias) => [ + alias.source, + redactEnvValue(alias.source, env[alias.source] ?? setupProvidedValue(alias.source, provided)), + ])), + }; + return { + ...readiness, + status: script ? missing.length === 0 ? "ready" : "missing" : "not_required", + script, + defaulted, + required: [...readiness.required, ...aliases.map((alias) => alias.source)], + configured, + missing, + values, + pipeline_env_required: automationPipelineEnvRequired(item), + env_aliases: aliases, + }; +} + +export function resolvedAutomationEnvOverrides(item: StructuredItem, env: EnvSource): Record { + const overrides: Record = {}; + for (const alias of automationEnvAliases(item, env)) { + const value = env[alias.source]; + if (value) overrides[alias.target] = value; + } + for (const [key, value] of Object.entries(automationEnvDefaults(item, env))) { + overrides[key] = expandEnvRefs(value, env); + } + if (automationPipelineEnvRequired(item)) overrides.LANGBOT_E2E_PIPELINE_REQUIRED = "1"; + return overrides; +} + +export function caseManualReadiness(item: StructuredItem): ManualReadiness { + const preconditions = listValue(item.fields, "preconditions"); + const setup = listValue(item.fields, "setup"); + const cleanup = listValue(item.fields, "cleanup"); + return { + status: preconditions.length > 0 || setup.length > 0 ? "manual_check" : "not_required", + preconditions, + setup, + cleanup, + }; +} + +export function caseFixtureReadiness(root: string, caseId: string): FixtureReadiness { + const fixtures = loadFixtureItems(root).items + .filter((item) => item.related_cases.includes(caseId)) + .map((item) => ({ + id: item.id, + kind: item.kind, + path: item.path, + exists: item.exists, + })); + const missing = fixtures.filter((item) => !item.exists).map((item) => item.id); + return { + status: fixtures.length === 0 ? "not_required" : missing.length === 0 ? "ready" : "missing", + required: fixtures, + missing, + }; +} diff --git a/skills/src/setup-automation.ts b/skills/src/setup-automation.ts new file mode 100644 index 0000000..808c04a --- /dev/null +++ b/skills/src/setup-automation.ts @@ -0,0 +1,90 @@ +import { existsSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { listValue } from "./fs.ts"; +import type { StructuredItem } from "./types.ts"; + +export type SetupAutomationSpec = { + entry: string; + kind: "case" | "node"; + target: string; + args: string[]; +}; + +export function setupAutomationEntries(item: StructuredItem): string[] { + return listValue(item.fields, "setup_automation"); +} + +export function parseSetupAutomationEntry(entry: string): SetupAutomationSpec { + const trimmed = entry.trim(); + if (trimmed.startsWith("case:")) { + return { + entry, + kind: "case", + target: trimmed.slice("case:".length).trim(), + args: [], + }; + } + if (trimmed.startsWith("node:")) { + const words = trimmed.slice("node:".length).trim().split(/\s+/).filter(Boolean); + return { + entry, + kind: "node", + target: words[0] ?? "", + args: words.slice(1), + }; + } + return { + entry, + kind: "case", + target: "", + args: [], + }; +} + +export function validateSetupAutomationEntry(root: string, entry: string, caseIds: Set): string[] { + const spec = parseSetupAutomationEntry(entry); + const errors: string[] = []; + if (!entry.startsWith("case:") && !entry.startsWith("node:")) { + return [`setup_automation entry must start with 'case:' or 'node:': ${entry}`]; + } + if (!spec.target) errors.push(`setup_automation entry is missing a target: ${entry}`); + if (spec.kind === "case") { + if (spec.args.length > 0) errors.push(`setup_automation case entries cannot include args: ${entry}`); + if (spec.target && !/^[a-z0-9][a-z0-9_-]*$/.test(spec.target)) { + errors.push(`setup_automation case target must be a case id: ${entry}`); + } else if (spec.target && !caseIds.has(spec.target)) { + errors.push(`setup_automation references unknown case '${spec.target}'`); + } + } + if (spec.kind === "node") { + if (spec.target.startsWith("/") || spec.target.includes("..") || !spec.target.startsWith("scripts/")) { + errors.push(`setup_automation node target must be a repository scripts/ path: ${entry}`); + } + if (spec.target && !/\.(mjs|js|ts)$/.test(spec.target)) { + errors.push(`setup_automation node target must be a Node script: ${entry}`); + } + if (spec.target && !existsSync(join(root, spec.target))) { + errors.push(`setup_automation node script does not exist: ${spec.target}`); + } + for (const arg of spec.args) { + if (!/^--[A-Za-z0-9][A-Za-z0-9_-]*(?:=[A-Za-z0-9_./:@-]+)?$/.test(arg)) { + errors.push(`setup_automation node arg must be a simple --flag or --key=value: ${entry}`); + } + } + } + return errors; +} + +export function setupAutomationEvidenceName(index: number, spec: SetupAutomationSpec): string { + const target = spec.kind === "case" ? spec.target : basename(spec.target).replace(/\.[^.]+$/, ""); + return `${String(index + 1).padStart(2, "0")}-${target.replace(/[^A-Za-z0-9_-]+/g, "-")}`; +} + +export function setupAutomationScriptPath(root: string, spec: SetupAutomationSpec): string { + return spec.kind === "node" && spec.target ? resolve(root, spec.target) : ""; +} + +export function lbsScriptPath(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "lbs.ts"); +} diff --git a/skills/src/types.ts b/skills/src/types.ts new file mode 100644 index 0000000..ae92642 --- /dev/null +++ b/skills/src/types.ts @@ -0,0 +1,25 @@ +export type Skill = { + path: string; + directory: string; + name: string; + description: string; + body: string; +}; + +export type CommandContext = { + root: string; + args: string[]; +}; + +export type ParsedYamlValue = string | boolean | string[]; + +export type ParsedYaml = Record; + +export type StructuredItem = { + path: string; + skill: string; + fields: ParsedYaml; + raw: string; +}; + +export type StructuredItemKind = "cases" | "suites" | "troubleshooting"; diff --git a/skills/test/lbs-cli.test.ts b/skills/test/lbs-cli.test.ts new file mode 100644 index 0000000..66c0411 --- /dev/null +++ b/skills/test/lbs-cli.test.ts @@ -0,0 +1,3362 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { appendFileSync, chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CommandContext } from "../src/types.ts"; +import { commandCaseList, commandCaseNew, commandCaseShow } from "../src/commands/case.ts"; +import { commandEnvDoctor, commandEnvShow } from "../src/commands/env.ts"; +import { commandFixtureCheck, commandFixtureList } from "../src/commands/fixture.ts"; +import { commandLogGuard, commandLogScan, commandLogWatch } from "../src/commands/log.ts"; +import { commandSuiteList, commandSuiteNew, commandSuitePlan, commandSuiteReport, commandSuiteRun, commandSuiteShow, commandSuiteStart } from "../src/commands/suite.ts"; +import { commandTestPlan, commandTestRecommend, commandTestReport, commandTestResult, commandTestRun, commandTestStart } from "../src/commands/test.ts"; +import { commandTroubleSearch } from "../src/commands/trouble.ts"; +import { commandValidate } from "../src/commands/validate.ts"; +import { commandIndex } from "../src/commands/skill.ts"; +import { loadEnv } from "../src/fs.ts"; +import { repoRoot } from "../src/cli.ts"; +import { + classifyDebugChatResult, + findNewFailureSignal, + minExpectedOccurrences, +} from "../scripts/e2e/lib/debug-chat.mjs"; + +const root = process.cwd(); + +test("repo root detects the skills tree before generated bin exists", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-root-no-bin-")); + try { + mkdirSync(join(tmp, "schemas"), { recursive: true }); + mkdirSync(join(tmp, "skills", "langbot-testing"), { recursive: true }); + writeFileSync(join(tmp, "skills.index.json"), "{}"); + writeFileSync(join(tmp, "schemas", "case.schema.json"), "{}"); + assert.equal(repoRoot(join(tmp, "skills", "langbot-testing")), tmp); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +function ctx(args: string[]): CommandContext { + return { root, args }; +} + +function capture(fn: () => number): { code: number; output: string } { + const originalLog = console.log; + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }; + try { + const code = fn(); + return { code, output: lines.join("\n") }; + } finally { + console.log = originalLog; + } +} + +function captureAll(fn: () => number): { code: number; output: string; error: string } { + const originalLog = console.log; + const originalWrite = process.stderr.write; + const lines: string[] = []; + const errors: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }; + process.stderr.write = ((chunk: string | Uint8Array) => { + errors.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + const code = fn(); + return { code, output: lines.join("\n"), error: errors.join("") }; + } finally { + console.log = originalLog; + process.stderr.write = originalWrite; + } +} + +function suiteResult(caseId: string, runId: string, status = "pass", evidence = ["ui", "screenshot", "console", "backend_log"]): string { + return JSON.stringify({ + source: "final", + case_id: caseId, + run_id: `${runId}-${caseId}`, + status, + reason: `${caseId} ${status}`, + started_at_local: "2026-05-21T10:30:00.000+08:00", + finished_at_local: "2026-05-21T10:31:00.000+08:00", + evidence_collected: evidence, + }); +} + +function withEnv(values: Record, fn: () => T): T { + const previous = new Map(Object.keys(values).map((key) => [key, process.env[key]])); + try { + for (const [key, value] of Object.entries(values)) process.env[key] = value; + return fn(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +async function captureAsync(fn: () => Promise): Promise<{ code: number; output: string }> { + const originalLog = console.log; + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }; + try { + const code = await fn(); + return { code, output: lines.join("\n") }; + } finally { + console.log = originalLog; + } +} + +test("validate accepts the repository assets", () => { + const result = capture(() => commandValidate(root)); + assert.equal(result.code, 0); + assert.match(result.output, /^OK/m); +}); + +test("validate allows blank shared env values but requires declared keys", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-validate-env-template-")); + try { + const schemasDir = join(tmp, "schemas"); + const skillsDir = join(tmp, "skills"); + const testingDir = join(skillsDir, "langbot-testing"); + mkdirSync(schemasDir, { recursive: true }); + mkdirSync(testingDir, { recursive: true }); + for (const schemaName of ["case.schema.json", "suite.schema.json", "troubleshooting.schema.json", "skill-index.schema.json"]) { + writeFileSync(join(schemasDir, schemaName), "{}"); + } + writeFileSync(join(testingDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + const envText = [ + "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000", + "LANGBOT_BACKEND_URL=http://127.0.0.1:5300", + "LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:3000", + "LANGBOT_REPO=", + "LANGBOT_WEB_REPO=", + "LANGBOT_BROWSER_PROFILE=", + "LANGBOT_CHROMIUM_EXECUTABLE=", + ].join("\n"); + writeFileSync(join(skillsDir, ".env"), envText); + writeFileSync(join(skillsDir, ".env.example"), envText); + + const result = capture(() => commandValidate(tmp)); + + assert.equal(result.code, 0); + assert.match(result.output, /^OK/m); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("index includes case summaries for agent discovery", () => { + const result = capture(() => commandIndex({ root, args: ["index"] })); + assert.equal(result.code, 0); + const index = JSON.parse(readFileSync(join(root, "skills.index.json"), "utf8")); + const testing = index.skills.find((skill: { name: string }) => skill.name === "langbot-testing"); + assert.ok(testing); + assert.ok(testing.case_summaries.some((item: { id: string; priority: string; evidence_required: string[] }) => ( + item.id === "pipeline-debug-chat" && item.priority === "p0" && item.evidence_required.includes("backend_log") + ))); + assert.ok(testing.case_summaries.some((item: { id: string; setup_automation: string[]; setup_provides_env: string[] }) => ( + item.id === "agent-runner-qa-debug-chat" && + item.setup_automation.includes("case:agent-runner-live-install") && + item.setup_provides_env.includes("LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL") + ))); + assert.ok(testing.suite_summaries.some((item: { id: string; cases: string[] }) => ( + item.id === "core-smoke" && item.cases.includes("pipeline-debug-chat") + ))); + assert.ok(testing.fixtures.some((item: { id: string; related_cases: string[] }) => ( + item.id === "mcp-stdio-echo-server" && item.related_cases.includes("mcp-stdio-tool-call") + ))); +}); + +test("index check detects stale index without writing", () => { + const path = join(root, "skills.index.json"); + const current = capture(() => commandIndex({ root, args: ["index"] })); + assert.equal(current.code, 0); + + const fresh = readFileSync(path, "utf8"); + try { + const ok = capture(() => commandIndex({ root, args: ["index", "--check"] })); + assert.equal(ok.code, 0); + assert.match(ok.output, /^OK /); + + writeFileSync(path, "{}\n"); + const stale = captureAll(() => commandIndex({ root, args: ["index", "--check"] })); + assert.equal(stale.code, 1); + assert.match(stale.error, /index is stale/); + assert.equal(readFileSync(path, "utf8"), "{}\n"); + } finally { + writeFileSync(path, fresh); + } +}); + +test("case list exposes seeded QA cases", () => { + const result = capture(() => commandCaseList(ctx(["case", "list"]))); + assert.equal(result.code, 0); + assert.match(result.output, /pipeline-debug-chat/); + assert.match(result.output, /provider-deepseek/); + assert.match(result.output, /webui-login-state/); +}); + +test("case list JSON filters by reusable agent-selection metadata", () => { + const result = capture(() => commandCaseList(ctx([ + "case", + "list", + "--json", + "--priority", + "p0", + "--automation", + ]))); + assert.equal(result.code, 0); + const rows = JSON.parse(result.output); + assert.ok(rows.length >= 2); + assert.ok(rows.every((row: { priority: string }) => row.priority === "p0")); + assert.ok(rows.every((row: { automation: string }) => row.automation)); + assert.ok(rows.some((row: { id: string; evidence_required: string[]; readiness: string }) => ( + row.id === "pipeline-debug-chat" && row.evidence_required.includes("backend_log") && row.readiness + ))); +}); + +test("case list distinguishes machine readiness from manual precondition checks", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-case-manual-readiness-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + mkdirSync(join(skillDir, "cases"), { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + writeFileSync(join(tmp, "skills", ".env"), "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000\n"); + writeFileSync( + join(skillDir, "cases", "manual-case.yaml"), + [ + "id: manual-case", + "title: Manual Case", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: medium", + "ci_eligible: false", + "tags:", + " - smoke", + "skills:", + " - langbot-testing", + "env:", + " - LANGBOT_FRONTEND_URL", + "preconditions:", + " - Confirm the target pipeline is safe to modify.", + "steps:", + " - Open the page.", + "checks:", + " - UI: Page opens.", + "evidence_required:", + " - ui", + ].join("\n"), + ); + + const machineReady = capture(() => commandCaseList({ root: tmp, args: ["case", "list", "--machine-ready"] })); + assert.equal(machineReady.code, 0); + assert.match(machineReady.output, /manual-case/); + assert.match(machineReady.output, /manual-check/); + + const ready = capture(() => commandCaseList({ root: tmp, args: ["case", "list", "--ready"] })); + assert.equal(ready.code, 0); + assert.doesNotMatch(ready.output, /manual-case/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("case show prints structured agent-browser case", () => { + const result = capture(() => commandCaseShow(ctx(["case", "show", "pipeline-debug-chat"]))); + assert.equal(result.code, 0); + assert.match(result.output, /^id: pipeline-debug-chat/m); + assert.match(result.output, /^mode: agent-browser/m); + assert.match(result.output, /^checks:/m); +}); + +test("case new writes required selection metadata", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-case-new-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + + const result = capture(() => commandCaseNew({ + root: tmp, + args: ["case", "new", "new-case", "--title", "New Case"], + })); + + assert.equal(result.code, 0); + const text = readFileSync(join(skillDir, "cases", "new-case.yaml"), "utf8"); + assert.match(text, /^priority: p2/m); + assert.match(text, /^risk: medium/m); + assert.match(text, /^ci_eligible: false/m); + assert.match(text, /^evidence_required:/m); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite list and plan expose reusable case groups", () => { + const list = capture(() => commandSuiteList(ctx(["suite", "list", "--json", "--priority", "p0"]))); + assert.equal(list.code, 0); + const suites = JSON.parse(list.output); + assert.ok(suites.some((suite: { id: string; cases: string[] }) => ( + suite.id === "core-smoke" && suite.cases.includes("webui-login-state") + ))); + + const plan = capture(() => commandSuitePlan(ctx(["suite", "plan", "core-smoke", "--json"]))); + assert.equal(plan.code, 0); + const suitePlan = JSON.parse(plan.output); + assert.equal(suitePlan.id, "core-smoke"); + assert.ok(suitePlan.cases.some((item: { id: string; evidence_required: string[] }) => ( + item.id === "pipeline-debug-chat" && item.evidence_required.includes("backend_log") + ))); + assert.ok(suitePlan.commands.some((item: { id: string; automation: string }) => ( + item.id === "pipeline-debug-chat" && item.automation.includes("test run") + ))); + + const localAgent = capture(() => commandSuitePlan(ctx(["suite", "plan", "local-agent-gate", "--json"]))); + assert.equal(localAgent.code, 0); + const localAgentPlan = JSON.parse(localAgent.output); + assert.ok(["ready", "missing", "manual_check"].includes(localAgentPlan.readiness.status)); + const basic = localAgentPlan.cases.find((item: { id: string }) => item.id === "local-agent-basic-debug-chat"); + assert.equal(basic.automation_readiness.pipeline_env_required, true); +}); + +test("suite show prints structured suite YAML", () => { + const result = capture(() => commandSuiteShow(ctx(["suite", "show", "local-agent-gate"]))); + assert.equal(result.code, 0); + assert.match(result.output, /^id: local-agent-gate/m); + assert.match(result.output, /^cases:/m); + assert.match(result.output, /local-agent-effective-prompt-debug-chat/); +}); + +test("suite start creates a run handoff with per-case evidence commands", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-start-")); + try { + const evidenceRoot = join(tmp, "evidence"); + const result = capture(() => commandSuiteStart(ctx([ + "suite", + "start", + "core-smoke", + "--run-id", + "core-smoke-local", + "--evidence-dir", + evidenceRoot, + "--json", + ]))); + assert.equal(result.code, 0); + const start = JSON.parse(result.output); + assert.equal(start.suite.id, "core-smoke"); + assert.equal(start.run_id, "core-smoke-local"); + assert.equal(start.evidence_root, evidenceRoot); + assert.equal(start.manifest_path, join(evidenceRoot, "suite-start.json")); + assert.equal(start.handoff_path, join(evidenceRoot, "suite-start.md")); + assert.match(start.report_command, /bin\/lbs suite report core-smoke/); + assert.ok(existsSync(join(evidenceRoot, "suite-start.json"))); + assert.ok(existsSync(join(evidenceRoot, "suite-start.md"))); + const pipeline = start.cases.find((item: { id: string }) => item.id === "pipeline-debug-chat"); + assert.ok(pipeline); + assert.ok(existsSync(join(evidenceRoot, "pipeline-debug-chat"))); + assert.match(pipeline.automation_command, /bin\/lbs test run pipeline-debug-chat/); + assert.match(pipeline.report_command, /--evidence-dir .+pipeline-debug-chat/); + assert.match(pipeline.result_command_template, /bin\/lbs test result pipeline-debug-chat/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite report aggregates case result JSON files", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-report-")); + try { + const evidenceRoot = join(tmp, "suite-evidence"); + const runId = "suite-report-run"; + for (const [caseId, status] of [ + ["webui-login-state", "pass"], + ["pipeline-debug-chat", "pass"], + ["local-agent-basic-debug-chat", "env_issue"], + ]) { + const dir = join(evidenceRoot, caseId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "result.json"), + suiteResult(caseId, runId, status), + ); + } + + const result = capture(() => commandSuiteReport(ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.status, "env_issue"); + assert.equal(report.counts.pass, 2); + assert.equal(report.counts.env_issue, 1); + assert.ok(report.cases.some((item: { id: string; result: { status: string } }) => ( + item.id === "local-agent-basic-debug-chat" && item.result.status === "env_issue" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite report treats pass without required evidence as incomplete", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-report-evidence-")); + try { + const evidenceRoot = join(tmp, "suite-evidence"); + const runId = "suite-report-evidence"; + for (const caseId of ["webui-login-state", "pipeline-debug-chat", "local-agent-basic-debug-chat"]) { + const dir = join(evidenceRoot, caseId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "result.json"), + suiteResult(caseId, runId, "pass", ["ui"]), + ); + } + + const result = capture(() => commandSuiteReport(ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.status, "incomplete"); + assert.ok(report.cases.some((item: { id: string; result: { evidence_missing: string[] } }) => ( + item.id === "pipeline-debug-chat" && item.result.evidence_missing.includes("backend_log") + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite report marks missing case evidence as incomplete", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-report-missing-")); + try { + const evidenceRoot = join(tmp, "suite-evidence"); + const runId = "suite-report-missing"; + mkdirSync(join(evidenceRoot, "webui-login-state"), { recursive: true }); + writeFileSync(join(evidenceRoot, "webui-login-state", "result.json"), suiteResult("webui-login-state", runId, "pass")); + + const result = capture(() => commandSuiteReport(ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.status, "incomplete"); + assert.ok(report.cases.some((item: { id: string; result: { status: string } }) => ( + item.id === "pipeline-debug-chat" && item.result.status === "missing" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite report rejects result files from the wrong case or run", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-report-mismatch-")); + try { + const evidenceRoot = join(tmp, "suite-evidence"); + const runId = "suite-report-mismatch"; + for (const caseId of ["webui-login-state", "pipeline-debug-chat", "local-agent-basic-debug-chat"]) { + const dir = join(evidenceRoot, caseId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "result.json"), suiteResult(caseId, runId, "pass")); + } + writeFileSync(join(evidenceRoot, "pipeline-debug-chat", "result.json"), suiteResult("webui-login-state", runId, "pass")); + writeFileSync(join(evidenceRoot, "local-agent-basic-debug-chat", "result.json"), suiteResult("local-agent-basic-debug-chat", "old-run", "pass")); + + const result = capture(() => commandSuiteReport(ctx([ + "suite", + "report", + "core-smoke", + "--run-id", + runId, + "--evidence-dir", + evidenceRoot, + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.status, "fail"); + assert.ok(report.cases.some((item: { id: string; result: { status: string; reason: string } }) => ( + item.id === "pipeline-debug-chat" && item.result.status === "invalid" && item.result.reason.includes("case_id mismatch") + ))); + assert.ok(report.cases.some((item: { id: string; result: { status: string; reason: string } }) => ( + item.id === "local-agent-basic-debug-chat" && item.result.status === "invalid" && item.result.reason.includes("run_id mismatch") + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run executes automated cases and aggregates a verdict", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "one.yaml"), + [ + "id: one", + "title: One", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/pass.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(casesDir, "two.yaml"), + [ + "id: two", + "title: Two", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/pass.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "mini.yaml"), + [ + "id: mini", + "title: Mini", + "description: Mini suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - one", + " - two", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "pass.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({", + " case_id: process.env.LBS_CASE_ID,", + " run_id: process.env.LBS_RUN_ID,", + " status: 'pass',", + " reason: `${process.env.LBS_CASE_ID} pass`,", + " evidence_collected: ['filesystem']", + "}));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass' }));", + ].join("\n"), + ); + + const result = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "mini", "--run-id", "mini-run", "--evidence-dir", join(tmp, "evidence"), "--json"], + })); + + assert.equal(result.code, 0); + const payload = JSON.parse(result.output); + assert.equal(payload.report.status, "pass"); + assert.equal(payload.report.counts.pass, 2); + assert.deepEqual(payload.executions.map((item: { status: string }) => item.status), ["ok", "ok"]); + assert.ok(existsSync(join(tmp, "evidence", "one", "result.json"))); + assert.ok(existsSync(join(tmp, "evidence", "two", "result.json"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run JSON captures failed case output", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-fail-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "fail-case.yaml"), + [ + "id: fail-case", + "title: Fail Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/fail.mjs", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "mini.yaml"), + [ + "id: mini", + "title: Mini", + "description: Mini suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - fail-case", + ].join("\n"), + ); + writeFileSync(join(scriptsDir, "fail.mjs"), "console.error('child failure detail'); process.exit(1);\n"); + + const result = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "mini", "--run-id", "mini-run", "--evidence-dir", join(tmp, "evidence"), "--json"], + })); + + assert.equal(result.code, 1); + const payload = JSON.parse(result.output); + assert.equal(payload.executions[0].status, "nonzero"); + assert.match(payload.executions[0].stderr, /child failure detail/); + assert.equal(payload.report.status, "fail"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run preserves classified env_issue automation results", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-env-issue-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "env-case.yaml"), + [ + "id: env-case", + "title: Env Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/env-issue.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "mini.yaml"), + [ + "id: mini", + "title: Mini", + "description: Mini suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - env-case", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "env-issue.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "const result = {", + " case_id: process.env.LBS_CASE_ID,", + " run_id: process.env.LBS_RUN_ID,", + " status: 'env_issue',", + " reason: 'backend not reachable',", + " evidence_collected: ['filesystem']", + "};", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify(result));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ ...result, source: 'automation' }));", + "process.exit(2);", + ].join("\n"), + ); + + const result = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "mini", "--run-id", "mini-run", "--evidence-dir", join(tmp, "evidence"), "--json"], + })); + + assert.equal(result.code, 2); + const payload = JSON.parse(result.output); + assert.equal(payload.executions[0].status, "classified"); + assert.equal(payload.report.status, "env_issue"); + assert.equal(payload.report.execution_status, "ok"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run failure cannot be masked by stale pass result", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-stale-pass-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + const evidenceDir = join(tmp, "evidence"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + mkdirSync(join(evidenceDir, "fail-case"), { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "fail-case.yaml"), + [ + "id: fail-case", + "title: Fail Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/fail.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "mini.yaml"), + [ + "id: mini", + "title: Mini", + "description: Mini suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - fail-case", + ].join("\n"), + ); + writeFileSync(join(scriptsDir, "fail.mjs"), "process.exit(1);\n"); + writeFileSync(join(evidenceDir, "fail-case", "result.json"), JSON.stringify({ + case_id: "fail-case", + run_id: "stale-run-fail-case", + status: "pass", + evidence_collected: ["filesystem"], + })); + + const result = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "mini", "--run-id", "stale-run", "--evidence-dir", evidenceDir, "--json"], + })); + + assert.equal(result.code, 1); + const payload = JSON.parse(result.output); + assert.equal(payload.executions[0].status, "nonzero"); + assert.equal(payload.report.status, "fail"); + assert.equal(payload.report.execution_status, "fail"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run dry-run plans automation without creating evidence", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-dry-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "dry-case.yaml"), + [ + "id: dry-case", + "title: Dry Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/fail-if-run.mjs", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "dry-suite.yaml"), + [ + "id: dry-suite", + "title: Dry Suite", + "description: Dry run suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - dry-case", + ].join("\n"), + ); + writeFileSync(join(scriptsDir, "fail-if-run.mjs"), "process.exit(9);\n"); + + const evidenceDir = join(tmp, "evidence"); + const result = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "dry-suite", "--run-id", "dry-run", "--evidence-dir", evidenceDir, "--dry-run", "--json"], + })); + + assert.equal(result.code, 0); + const payload = JSON.parse(result.output); + assert.equal(payload.executions[0].status, "planned"); + assert.match(payload.executions[0].command, /test run dry-case/); + assert.equal(payload.report.status, "incomplete"); + assert.equal(existsSync(evidenceDir), false); + assert.equal(existsSync(join(tmp, "reports", "dry-run.md")), false); + + const markdown = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "dry-suite", "--run-id", "dry-run-markdown", "--evidence-dir", join(tmp, "evidence-md"), "--dry-run"], + })); + assert.equal(markdown.code, 0); + assert.match(markdown.output, /# Suite Report: dry-suite/); + assert.equal(existsSync(join(tmp, "reports", "dry-run-markdown.md")), false); + assert.equal(existsSync(join(tmp, "evidence-md")), false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run skips manual-check cases unless explicitly included", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-manual-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "manual-case.yaml"), + [ + "id: manual-case", + "title: Manual Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "preconditions:", + " - Confirm this case is safe to run.", + "automation: scripts/pass.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(suitesDir, "manual-suite.yaml"), + [ + "id: manual-suite", + "title: Manual Suite", + "description: Manual check suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - manual-case", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "pass.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ case_id: process.env.LBS_CASE_ID, run_id: process.env.LBS_RUN_ID, status: 'pass', evidence_collected: ['filesystem'] }));", + ].join("\n"), + ); + + const skipped = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "manual-suite", "--run-id", "manual-run", "--evidence-dir", join(tmp, "evidence"), "--json"], + })); + assert.equal(skipped.code, 1); + const skippedPayload = JSON.parse(skipped.output); + assert.equal(skippedPayload.executions[0].status, "skipped"); + assert.match(skippedPayload.executions[0].reason, /manual_check/); + assert.equal(existsSync(join(tmp, "evidence", "manual-case", "result.json")), false); + + const included = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "manual-suite", "--run-id", "manual-run-included", "--evidence-dir", join(tmp, "evidence-included"), "--include-manual-check", "--json"], + })); + assert.equal(included.code, 0); + const includedPayload = JSON.parse(included.output); + assert.equal(includedPayload.executions[0].status, "ok"); + assert.equal(includedPayload.report.status, "pass"); + assert.ok(existsSync(join(tmp, "evidence-included", "manual-case", "result.json"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite run skips cases with missing machine readiness unless explicitly included", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-readiness-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const suitesDir = join(skillDir, "suites"); + const fixturesDir = join(skillDir, "fixtures"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(suitesDir, { recursive: true }); + mkdirSync(fixturesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "not-ready-case.yaml"), + [ + "id: not-ready-case", + "title: Not Ready Case", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "env:", + " - LBS_TEST_SUITE_RUN_MISSING_ENV", + "automation_env:", + " - LBS_TEST_SUITE_RUN_MISSING_AUTOMATION_ENV", + "automation: scripts/pass.mjs", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + writeFileSync( + join(fixturesDir, "fixtures.json"), + `${JSON.stringify([{ + id: "missing-fixture", + title: "Missing fixture", + kind: "file", + path: "fixtures/missing.txt", + related_cases: ["not-ready-case"], + checks: ["exists"], + }], null, 2)}\n`, + ); + writeFileSync( + join(suitesDir, "readiness-suite.yaml"), + [ + "id: readiness-suite", + "title: Readiness Suite", + "description: Readiness suite.", + "type: smoke", + "priority: p2", + "tags:", + " - qa", + "cases:", + " - not-ready-case", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "pass.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ case_id: process.env.LBS_CASE_ID, run_id: process.env.LBS_RUN_ID, status: 'pass', evidence_collected: ['filesystem'] }));", + ].join("\n"), + ); + + const skipped = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "readiness-suite", "--run-id", "readiness-run", "--evidence-dir", join(tmp, "evidence"), "--json"], + })); + assert.equal(skipped.code, 1); + const skippedPayload = JSON.parse(skipped.output); + assert.equal(skippedPayload.executions[0].status, "skipped"); + assert.match(skippedPayload.executions[0].reason, /readiness missing/); + assert.match(skippedPayload.executions[0].reason, /LBS_TEST_SUITE_RUN_MISSING_ENV/); + assert.match(skippedPayload.executions[0].reason, /LBS_TEST_SUITE_RUN_MISSING_AUTOMATION_ENV/); + assert.match(skippedPayload.executions[0].reason, /missing-fixture/); + assert.equal(existsSync(join(tmp, "evidence", "not-ready-case", "result.json")), false); + + const included = capture(() => commandSuiteRun({ + root: tmp, + args: ["suite", "run", "readiness-suite", "--run-id", "readiness-run-included", "--evidence-dir", join(tmp, "evidence-included"), "--include-not-ready", "--json"], + })); + assert.equal(included.code, 0); + const includedPayload = JSON.parse(included.output); + assert.equal(includedPayload.executions[0].status, "ok"); + assert.equal(includedPayload.report.status, "pass"); + assert.ok(existsSync(join(tmp, "evidence-included", "not-ready-case", "result.json"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("suite new writes a reusable suite skeleton", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-new-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + + const result = capture(() => commandSuiteNew({ + root: tmp, + args: ["suite", "new", "new-suite", "--title", "New Suite"], + })); + + assert.equal(result.code, 0); + const text = readFileSync(join(skillDir, "suites", "new-suite.yaml"), "utf8"); + assert.match(text, /^description:/m); + assert.match(text, /^priority: p2/m); + assert.match(text, /^cases:/m); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("fixture list and check expose reusable fixture readiness", () => { + const list = capture(() => commandFixtureList(ctx(["fixture", "list", "langbot-testing", "--json"]))); + assert.equal(list.code, 0); + const fixtures = JSON.parse(list.output); + assert.ok(fixtures.some((item: { id: string; exists: boolean }) => ( + item.id === "mcp-stdio-echo-server" && item.exists === true + ))); + + const check = capture(() => commandFixtureCheck(ctx(["fixture", "check", "langbot-testing", "--json"]))); + assert.equal(check.code, 0); + const report = JSON.parse(check.output); + assert.equal(report.status, "pass"); + assert.ok(report.fixtures.some((item: { id: string }) => item.id === "qa-plugin-smoke-package")); +}); + +test("fixture check reports missing manifest paths", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-fixture-check-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + mkdirSync(join(skillDir, "fixtures"), { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + writeFileSync( + join(skillDir, "fixtures", "fixtures.json"), + JSON.stringify([{ id: "missing-fixture", title: "Missing Fixture", path: "fixtures/missing.txt" }]), + ); + + const result = capture(() => commandFixtureCheck({ root: tmp, args: ["fixture", "check", "langbot-testing", "--json"] })); + + assert.equal(result.code, 1); + const report = JSON.parse(result.output); + assert.equal(report.status, "fail"); + assert.ok(report.findings.some((finding: { id?: string }) => finding.id === "missing-fixture")); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("fixture check verifies QA AgentRunner source shape", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-fixture-check-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const fixtureDir = join(skillDir, "fixtures", "plugins", "qa-agent-runner"); + mkdirSync(join(fixtureDir, "components", "agent_runner"), { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + writeFileSync( + join(skillDir, "fixtures", "fixtures.json"), + JSON.stringify([{ + id: "qa-agent-runner-source", + title: "QA AgentRunner", + path: "fixtures/plugins/qa-agent-runner/manifest.yaml", + checks: ["exists", "qa_agent_runner_source"], + }]), + ); + writeFileSync(join(fixtureDir, "manifest.yaml"), "spec:\n components:\n AgentRunner: {}\nexecution:\n python:\n attr: QAAgentRunnerPlugin\n"); + + const result = capture(() => commandFixtureCheck({ root: tmp, args: ["fixture", "check", "langbot-testing", "--json"] })); + + assert.equal(result.code, 1); + const report = JSON.parse(result.output); + assert.ok(report.findings.some((finding: { kind?: string; path?: string }) => ( + finding.kind === "fixture_check_missing_file" + && finding.path?.endsWith("components/agent_runner/default.py") + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("fixture check accepts complete QA AgentRunner source shape", () => { + const result = capture(() => commandFixtureCheck(ctx(["fixture", "check", "langbot-testing", "--json"]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.ok(report.fixtures.some((item: { id: string; checks: string[] }) => ( + item.id === "qa-agent-runner-source" && item.checks.includes("qa_agent_runner_source") + ))); +}); + +test("fixture check rejects invalid plugin package files", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-fixture-check-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + mkdirSync(join(skillDir, "fixtures"), { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n", + ); + writeFileSync(join(skillDir, "fixtures", "bad.lbpkg"), "not a zip"); + writeFileSync( + join(skillDir, "fixtures", "fixtures.json"), + JSON.stringify([{ + id: "bad-package", + title: "Bad Package", + path: "fixtures/bad.lbpkg", + checks: ["exists", "zip_package"], + }]), + ); + + const result = capture(() => commandFixtureCheck({ root: tmp, args: ["fixture", "check", "langbot-testing", "--json"] })); + + assert.equal(result.code, 1); + const report = JSON.parse(result.output); + assert.ok(report.findings.some((finding: { kind?: string; id?: string }) => ( + finding.kind === "fixture_check_invalid_zip" && finding.id === "bad-package" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("debug chat classifier prefers latest response leaf over body counts", () => { + const result = classifyDebugChatResult({ + beforeText: "OK from an older chat", + afterText: "OK from an older chat\nUser: say OK\nBot: OK", + expectedText: "OK", + prompt: "say OK", + latestExpectedLeaf: "Bot: OK", + latestFailureLeaf: "", + }); + + assert.equal(result.status, "pass"); + assert.match(result.reason, /latest visible response leaf/); +}); + +test("debug chat classifier distinguishes new failure signals from old history", () => { + assert.equal( + findNewFailureSignal("Agent runner temporarily unavailable", "Agent runner temporarily unavailable"), + "", + ); + assert.equal( + findNewFailureSignal("", "Agent runner temporarily unavailable"), + "Agent runner temporarily unavailable", + ); + + const result = classifyDebugChatResult({ + beforeText: "", + afterText: "Agent runner temporarily unavailable", + expectedText: "OK", + prompt: "say OK", + latestExpectedLeaf: "", + latestFailureLeaf: "Agent runner temporarily unavailable", + }); + + assert.equal(result.status, "fail"); + assert.match(result.reason, /known failure signal/); + + const custom = classifyDebugChatResult({ + beforeText: "", + afterText: "Bot: qa-plugin-smoke:mcp-ok-local-agent", + expectedText: "qa_mcp_echo:mcp-ok-local-agent", + prompt: "call mcp", + latestExpectedLeaf: "", + latestFailureLeaf: "Bot: qa-plugin-smoke:mcp-ok-local-agent", + failureSignals: ["qa-plugin-smoke:mcp-ok-local-agent"], + }); + + assert.equal(custom.status, "fail"); + assert.equal(custom.failure_signal, "qa-plugin-smoke:mcp-ok-local-agent"); +}); + +test("debug chat classifier lets new failure signals override stale expected history", () => { + const result = classifyDebugChatResult({ + beforeText: "Bot: qa_mcp_echo:mcp-ok-local-agent", + afterText: [ + "Bot: qa_mcp_echo:mcp-ok-local-agent", + "User: Call qa_mcp_echo", + "Bot: Agent runner temporarily unavailable.", + ].join("\n"), + expectedText: "qa_mcp_echo:mcp-ok-local-agent", + prompt: "Call qa_mcp_echo", + latestExpectedLeaf: "Bot: qa_mcp_echo:mcp-ok-local-agent", + latestFailureLeaf: "Bot: Agent runner temporarily unavailable.", + }); + + assert.equal(result.status, "fail"); + assert.equal(result.failure_signal, "Agent runner temporarily unavailable"); +}); + +test("debug chat classifier does not pass on stale expected history without a new occurrence", () => { + const result = classifyDebugChatResult({ + beforeText: "Bot: qa_mcp_echo:mcp-ok-local-agent", + afterText: [ + "Bot: qa_mcp_echo:mcp-ok-local-agent", + "User: Call qa_mcp_echo", + ].join("\n"), + expectedText: "qa_mcp_echo:mcp-ok-local-agent", + prompt: "Call qa_mcp_echo", + latestExpectedLeaf: "Bot: qa_mcp_echo:mcp-ok-local-agent", + latestFailureLeaf: "", + }); + + assert.equal(result.status, "fail"); + assert.equal(result.final_count, 1); + assert.equal(result.min_expected_count, 2); +}); + +test("debug chat classifier accounts for prompt echo occurrences", () => { + assert.equal(minExpectedOccurrences("", "OK", "say OK"), 2); + const result = classifyDebugChatResult({ + beforeText: "", + afterText: "User: say OK", + expectedText: "OK", + prompt: "say OK", + latestExpectedLeaf: "User: say OK", + latestFailureLeaf: "", + }); + + assert.equal(result.status, "fail"); + assert.equal(result.min_expected_count, 2); + assert.equal(result.final_count, 1); +}); + +test("debug chat classifier requires new assistant evidence when message bubbles are available", () => { + const prompt = "If all steps succeed, final answer must be E2E_OK:skill"; + const result = classifyDebugChatResult({ + beforeText: "", + afterText: `User: ${prompt}`, + expectedText: "E2E_OK:skill", + prompt, + latestExpectedLeaf: prompt, + latestFailureLeaf: "", + beforeMessages: [], + afterMessages: [{ role: "user", text: prompt }], + latestAssistantText: "", + }); + + assert.equal(result.status, "fail"); + assert.match(result.reason, /new assistant message/); + assert.equal(result.before_assistant_expected_count, 0); + assert.equal(result.after_assistant_expected_count, 0); +}); + +test("debug chat classifier passes when expected text appears in a new assistant message", () => { + const prompt = "Return only E2E_OK:skill"; + const result = classifyDebugChatResult({ + beforeText: "", + afterText: `User: ${prompt}\nBot: E2E_OK:skill`, + expectedText: "E2E_OK:skill", + prompt, + latestExpectedLeaf: "E2E_OK:skill", + latestFailureLeaf: "", + beforeMessages: [], + afterMessages: [ + { role: "user", text: prompt }, + { role: "assistant", text: "E2E_OK:skill" }, + ], + latestAssistantText: "E2E_OK:skill", + }); + + assert.equal(result.status, "pass"); + assert.match(result.reason, /new assistant message/); + assert.equal(result.before_assistant_expected_count, 0); + assert.equal(result.after_assistant_expected_count, 1); +}); + +test("debug chat classifier allows a recovered failure when latest assistant is successful", () => { + const expectedText = "E2E_OK:skill"; + const result = classifyDebugChatResult({ + beforeText: "", + afterText: [ + "Bot: Agent runner temporarily unavailable", + `Bot: recovered and completed ${expectedText}`, + ].join("\n"), + expectedText, + prompt: "Modify the existing skill", + latestExpectedLeaf: `recovered and completed ${expectedText}`, + latestFailureLeaf: "Agent runner temporarily unavailable", + beforeMessages: [], + afterMessages: [ + { role: "assistant", text: "Agent runner temporarily unavailable" }, + { role: "assistant", text: `recovered and completed ${expectedText}` }, + ], + latestAssistantText: `recovered and completed ${expectedText}`, + }); + + assert.equal(result.status, "pass"); + assert.equal(result.before_assistant_expected_count, 0); + assert.equal(result.after_assistant_expected_count, 1); +}); + +test("env doctor explains a missing backend listener with a startup hint", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-env-doctor-")); + try { + const skillsDir = join(tmp, "skills"); + const repoDir = join(tmp, "LangBot"); + const webDir = join(repoDir, "web"); + const browserProfile = join(tmp, "browser-profile"); + const chromium = join(tmp, "chromium"); + mkdirSync(skillsDir, { recursive: true }); + mkdirSync(webDir, { recursive: true }); + mkdirSync(browserProfile, { recursive: true }); + writeFileSync(chromium, ""); + writeFileSync( + join(skillsDir, ".env"), + [ + "LANGBOT_BACKEND_URL=http://127.0.0.1:59998", + "LANGBOT_FRONTEND_URL=http://127.0.0.1:59998", + "LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:59998", + `LANGBOT_REPO=${repoDir}`, + `LANGBOT_WEB_REPO=${webDir}`, + `LANGBOT_BROWSER_PROFILE=${browserProfile}`, + `LANGBOT_CHROMIUM_EXECUTABLE=${chromium}`, + "LANGBOT_PROXY_HTTP=http://127.0.0.1:7890", + "LANGBOT_PROXY_SOCKS=socks5://127.0.0.1:7890", + "LANGBOT_NO_PROXY=localhost,127.0.0.1,::1", + ].join("\n"), + ); + + const result = await captureAsync(() => commandEnvDoctor({ root: tmp, args: ["env", "doctor"] })); + + assert.equal(result.code, 1); + assert.match(result.output, /FAIL: LANGBOT_BACKEND_URL: no HTTP service reachable because 127\.0\.0\.1:59998 is not listening/); + assert.match(result.output, new RegExp(`WARN: LANGBOT_BACKEND_URL: start backend: cd ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} && uv run main.py`)); + assert.match(result.output, new RegExp(`WARN: LANGBOT_FRONTEND_URL: start frontend: cd ${webDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} && pnpm dev`)); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("env doctor does not require proxy variables", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-env-doctor-no-proxy-")); + try { + const skillsDir = join(tmp, "skills"); + const repoDir = join(tmp, "LangBot"); + const webDir = join(repoDir, "web"); + const browserProfile = join(tmp, "browser-profile"); + const chromium = join(tmp, "chromium"); + mkdirSync(skillsDir, { recursive: true }); + mkdirSync(webDir, { recursive: true }); + mkdirSync(browserProfile, { recursive: true }); + writeFileSync(chromium, ""); + writeFileSync( + join(skillsDir, ".env"), + [ + "LANGBOT_BACKEND_URL=http://127.0.0.1:59997", + "LANGBOT_FRONTEND_URL=http://127.0.0.1:59997", + "LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:59997", + `LANGBOT_REPO=${repoDir}`, + `LANGBOT_WEB_REPO=${webDir}`, + `LANGBOT_BROWSER_PROFILE=${browserProfile}`, + `LANGBOT_CHROMIUM_EXECUTABLE=${chromium}`, + ].join("\n"), + ); + + const result = await captureAsync(() => commandEnvDoctor({ root: tmp, args: ["env", "doctor"] })); + + assert.equal(result.code, 1); + assert.doesNotMatch(result.output, /missing LANGBOT_PROXY|missing LANGBOT_NO_PROXY/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("env doctor reports missing socksio for active SOCKS proxy", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-env-doctor-socksio-")); + const originalAllProxy = process.env.ALL_PROXY; + const originalAllProxyLower = process.env.all_proxy; + try { + delete process.env.ALL_PROXY; + delete process.env.all_proxy; + const skillsDir = join(tmp, "skills"); + const repoDir = join(tmp, "LangBot"); + const webDir = join(repoDir, "web"); + const venvBin = join(repoDir, ".venv", "bin"); + const browserProfile = join(tmp, "browser-profile"); + const chromium = join(tmp, "chromium"); + mkdirSync(skillsDir, { recursive: true }); + mkdirSync(webDir, { recursive: true }); + mkdirSync(venvBin, { recursive: true }); + mkdirSync(browserProfile, { recursive: true }); + writeFileSync(chromium, ""); + const python = join(venvBin, "python"); + writeFileSync(python, "#!/bin/sh\nexit 1\n"); + chmodSync(python, 0o755); + writeFileSync( + join(skillsDir, ".env"), + [ + "LANGBOT_BACKEND_URL=http://127.0.0.1:59996", + "LANGBOT_FRONTEND_URL=http://127.0.0.1:59996", + "LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:59996", + `LANGBOT_REPO=${repoDir}`, + `LANGBOT_WEB_REPO=${webDir}`, + `LANGBOT_BROWSER_PROFILE=${browserProfile}`, + `LANGBOT_CHROMIUM_EXECUTABLE=${chromium}`, + "ALL_PROXY=socks5://127.0.0.1:7890", + ].join("\n"), + ); + + const result = await captureAsync(() => commandEnvDoctor({ root: tmp, args: ["env", "doctor"] })); + + assert.equal(result.code, 1); + assert.match(result.output, /FAIL: SOCKS proxy ALL_PROXY is configured/); + assert.match(result.output, /cannot import socksio/); + assert.match(result.output, /-m pip install socksio/); + } finally { + if (originalAllProxy === undefined) delete process.env.ALL_PROXY; + else process.env.ALL_PROXY = originalAllProxy; + if (originalAllProxyLower === undefined) delete process.env.all_proxy; + else process.env.all_proxy = originalAllProxyLower; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("env show redacts secret-like values by default", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-env-show-redact-")); + try { + mkdirSync(join(tmp, "skills"), { recursive: true }); + writeFileSync( + join(tmp, "skills", ".env"), + [ + "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000", + "LANGBOT_API_KEY=sk-test-secret", + "LANGBOT_PROXY_HTTP=http://user:pass@127.0.0.1:7890", + ].join("\n"), + ); + + const text = capture(() => commandEnvShow({ root: tmp, args: ["env", "show"] })); + assert.equal(text.code, 0); + assert.match(text.output, /LANGBOT_API_KEY=\[redacted\]/); + assert.match(text.output, /LANGBOT_PROXY_HTTP=http:\/\/\[redacted\]@127\.0\.0\.1:7890/); + assert.doesNotMatch(text.output, /sk-test-secret|user:pass/); + + const json = capture(() => commandEnvShow({ root: tmp, args: ["env", "show", "--json"] })); + assert.equal(json.code, 0); + const parsed = JSON.parse(json.output); + assert.equal(parsed.LANGBOT_API_KEY, "[redacted]"); + assert.equal(parsed.LANGBOT_PROXY_HTTP, "http://[redacted]@127.0.0.1:7890"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test plan renders agent-browser QA guidance", () => { + const result = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat"]))); + assert.equal(result.code, 0); + assert.match(result.output, /Mode: agent-browser/); + assert.match(result.output, /Use browser\/UI interaction as the primary QA path/); + assert.match(result.output, /API\/curl\/log checks are diagnostic only/); + assert.match(result.output, /## Browser Steps/); + assert.match(result.output, /## Success Signals/); + assert.match(result.output, /## Required Evidence/); + assert.match(result.output, /## Automation Readiness/); + assert.match(result.output, /## Fixture Readiness/); + assert.match(result.output, /## Manual Readiness/); + assert.match(result.output, /backend_log/); +}); + +test("test plan JSON is parseable and includes troubleshooting patterns", () => { + const result = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); + assert.equal(result.code, 0); + const plan = JSON.parse(result.output); + assert.equal(plan.id, "pipeline-debug-chat"); + assert.equal(plan.mode, "agent-browser"); + assert.ok(["ready", "missing"].includes(plan.automation_readiness.status)); + assert.ok(plan.automation_readiness.defaulted.includes("LANGBOT_E2E_PROMPT")); + assert.ok(plan.automation_readiness.defaulted.includes("LANGBOT_E2E_EXPECTED_TEXT")); + assert.equal(plan.manual_readiness.status, "manual_check"); + assert.ok(plan.success_patterns.includes("Streaming completed")); + assert.ok(plan.troubleshooting.some((entry: { id: string }) => entry.id === "plugin-runtime-timeout")); +}); + +test("test plan JSON exposes missing case-specific pipeline readiness", () => { + const result = capture(() => commandTestPlan(ctx(["test", "plan", "local-agent-basic-debug-chat", "--json"]))); + assert.equal(result.code, 0); + const plan = JSON.parse(result.output); + assert.equal(plan.env_readiness.status, "ready"); + assert.ok(["ready", "missing"].includes(plan.automation_readiness.status)); + assert.ok(plan.automation_readiness.pipeline_env_required); + assert.ok( + plan.automation_readiness.missing.includes("LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME") + || plan.automation_readiness.configured.some((key: string) => key.startsWith("LANGBOT_LOCAL_AGENT_PIPELINE_")), + ); +}); + +test("generic pipeline readiness accepts either URL or name target", () => { + const originalUrl = process.env.LANGBOT_PIPELINE_URL; + const originalName = process.env.LANGBOT_PIPELINE_NAME; + try { + withEnv({ + LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", + LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", + }, () => { + process.env.LANGBOT_PIPELINE_URL = "http://127.0.0.1:3000/home/pipelines?id=only-url"; + process.env.LANGBOT_PIPELINE_NAME = ""; + + const ready = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); + assert.equal(ready.code, 0); + const plan = JSON.parse(ready.output); + assert.equal(plan.env_readiness.status, "ready"); + assert.equal(plan.automation_readiness.status, "ready"); + assert.ok(plan.automation_readiness.required.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); + }); + + process.env.LANGBOT_PIPELINE_URL = ""; + process.env.LANGBOT_PIPELINE_NAME = ""; + + const missing = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); + assert.equal(missing.code, 0); + const missingPlan = JSON.parse(missing.output); + assert.equal(missingPlan.env_readiness.status, "missing"); + assert.ok(missingPlan.env_readiness.missing.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); + assert.equal(missingPlan.automation_readiness.status, "missing"); + assert.ok(missingPlan.automation_readiness.missing.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); + } finally { + if (originalUrl === undefined) delete process.env.LANGBOT_PIPELINE_URL; + else process.env.LANGBOT_PIPELINE_URL = originalUrl; + if (originalName === undefined) delete process.env.LANGBOT_PIPELINE_NAME; + else process.env.LANGBOT_PIPELINE_NAME = originalName; + } +}); + +test("test recommend maps AgentRunner ledger changes to focused probes", () => { + const result = capture(() => commandTestRecommend(ctx([ + "test", + "recommend", + "--file", + "LangBot/src/langbot/pkg/agent/runner/run_ledger_store.py", + "--file", + "LangBot/tests/unit_tests/agent/test_run_ledger_store.py", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + const ids = report.recommendations.map((item: { id: string }) => item.id); + assert.ok(ids.includes("agent-runner-ledger-invariants")); + assert.ok(ids.includes("agent-runner-ledger-stress")); + assert.ok(ids.includes("agent-runner-ledger-contention")); + assert.ok(ids.includes("agent-runner-async-db-readiness")); + assert.ok(ids.includes("agent-runner-ledger-concurrency")); + assert.ok(report.commands.every((command: string) => !command.startsWith("bin/lbs test run ") || command.endsWith(" --dry-run"))); + assert.ok(report.notes.some((note: string) => note.includes("Remove --dry-run"))); +}); + +test("test recommend maps AgentRunner result changes to fixture contract", () => { + const result = capture(() => commandTestRecommend(ctx([ + "test", + "recommend", + "--file", + "langbot-plugin-sdk/src/langbot_plugin/api/entities/builtin/agent_runner/result.py", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + const ids = report.recommendations.map((item: { id: string }) => item.id); + assert.ok(ids.includes("agent-runner-fixture-contract")); + assert.ok(ids.includes("agent-runner-behavior-matrix")); + assert.ok(!ids.includes("agent-runner-ledger-invariants")); +}); + +test("test recommend maps QA AgentRunner fixture changes to live install", () => { + const result = capture(() => commandTestRecommend(ctx([ + "test", + "recommend", + "--file", + "langbot-skills/skills/langbot-testing/fixtures/plugins/qa-agent-runner/components/agent_runner/default.py", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + const ids = report.recommendations.map((item: { id: string }) => item.id); + assert.ok(ids.includes("agent-runner-fixture-contract")); + assert.ok(ids.includes("agent-runner-live-install")); + assert.ok(ids.includes("agent-runner-qa-debug-chat")); +}); + +test("test recommend maps QA plugin smoke fixture changes to live install", () => { + const result = capture(() => commandTestRecommend(ctx([ + "test", + "recommend", + "--file", + "langbot-skills/skills/langbot-testing/fixtures/plugins/qa-plugin-smoke/main.py", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + const ids = report.recommendations.map((item: { id: string }) => item.id); + assert.ok(ids.includes("qa-plugin-smoke-live-install")); +}); + +test("test recommend keeps git status paths intact", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-recommend-git-")); + const originalRepos = { + LANGBOT_REPO: process.env.LANGBOT_REPO, + LANGBOT_PLUGIN_SDK_REPO: process.env.LANGBOT_PLUGIN_SDK_REPO, + LANGBOT_AGENT_RUNNER_REPO: process.env.LANGBOT_AGENT_RUNNER_REPO, + LANGBOT_LOCAL_AGENT_REPO: process.env.LANGBOT_LOCAL_AGENT_REPO, + }; + try { + const repo = join(tmp, "LangBot"); + mkdirSync(join(repo, "src", "langbot", "pkg", "agent", "runner"), { recursive: true }); + spawnSync("git", ["init"], { cwd: repo }); + spawnSync("git", ["config", "user.email", "qa@example.test"], { cwd: repo }); + spawnSync("git", ["config", "user.name", "QA"], { cwd: repo }); + writeFileSync(join(repo, "README.md"), "test\n"); + writeFileSync(join(repo, "src", "langbot", "pkg", "agent", "runner", "run_ledger_store.py"), "# test\n"); + spawnSync("git", ["add", "README.md", "src/langbot/pkg/agent/runner/run_ledger_store.py"], { cwd: repo }); + spawnSync("git", ["commit", "-m", "init"], { cwd: repo }); + writeFileSync(join(repo, "src", "langbot", "pkg", "agent", "runner", "run_ledger_store.py"), "# changed\n"); + + process.env.LANGBOT_REPO = repo; + process.env.LANGBOT_PLUGIN_SDK_REPO = join(tmp, "missing-sdk"); + process.env.LANGBOT_AGENT_RUNNER_REPO = join(tmp, "missing-runner"); + process.env.LANGBOT_LOCAL_AGENT_REPO = join(tmp, "missing-local"); + const result = capture(() => commandTestRecommend({ root, args: ["test", "recommend", "--json"] })); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.ok(report.changed_files.includes("LangBot/src/langbot/pkg/agent/runner/run_ledger_store.py")); + assert.ok(!report.changed_files.some((file: string) => file.includes("LangBot/rc/"))); + } finally { + for (const [key, value] of Object.entries(originalRepos)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test start creates a run handoff with a bounded report command", () => { + const result = capture(() => commandTestStart(ctx(["test", "start", "pipeline-debug-chat"]))); + assert.equal(result.code, 0); + assert.match(result.output, /^# Test Start: pipeline-debug-chat/m); + assert.match(result.output, /bin\/lbs test plan pipeline-debug-chat/); + assert.match(result.output, /bin\/lbs test run pipeline-debug-chat --run-id .+ --output reports\/evidence\/.+pipeline-debug-chat/); + assert.match(result.output, /bin\/lbs test report pipeline-debug-chat --since ".+" --console-log reports\/evidence\/.+\/console\.log --evidence-dir reports\/evidence\/.+ --output reports\/.+pipeline-debug-chat\.md/); + assert.match(result.output, /Streaming completed/); +}); + +test("test start JSON is parseable for agent orchestration", () => { + const result = capture(() => commandTestStart(ctx(["test", "start", "pipeline-debug-chat", "--json"]))); + assert.equal(result.code, 0); + const start = JSON.parse(result.output); + assert.equal(start.case.id, "pipeline-debug-chat"); + assert.match(start.run_id, /pipeline-debug-chat$/); + assert.match(start.started_at_local, /\d{4}-\d{2}-\d{2}T/); + assert.match(start.report_command, /--since/); + assert.match(start.result_command_template, /bin\/lbs test result pipeline-debug-chat/); + assert.match(start.automation.command, /bin\/lbs test run pipeline-debug-chat/); + assert.ok(start.success_patterns.includes("Streaming completed")); + assert.ok(start.evidence_required.includes("backend_log")); +}); + +test("test result writes a suite-readable result.json and enforces pass evidence", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-test-result-")); + try { + const evidenceDir = join(tmp, "pipeline-run"); + const ok = capture(() => commandTestResult(ctx([ + "test", + "result", + "pipeline-debug-chat", + "--result", + "pass", + "--reason", + "Debug Chat returned OK and logs were clean.", + "--evidence-dir", + evidenceDir, + "--started-at", + "2026-05-21T10:30:00.000+08:00", + "--evidence", + "ui,screenshot,console,backend_log", + "--json", + ]))); + + assert.equal(ok.code, 0); + const record = JSON.parse(ok.output); + assert.equal(record.source, "final"); + assert.equal(record.status, "pass"); + assert.equal(record.evidence_status, "complete"); + assert.deepEqual(record.evidence_missing, []); + assert.equal(JSON.parse(readFileSync(join(evidenceDir, "result.json"), "utf8")).case_id, "pipeline-debug-chat"); + + const missing = captureAll(() => commandTestResult(ctx([ + "test", + "result", + "pipeline-debug-chat", + "--result", + "pass", + "--reason", + "Missing backend evidence should not be accepted as pass.", + "--evidence-dir", + join(tmp, "missing-evidence"), + "--evidence", + "ui", + ]))); + assert.equal(missing.code, 1); + assert.match(missing.error, /missing required evidence/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run dry-run exposes case automation script and evidence paths", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "pipeline-debug-chat", + "--run-id", + "run-123", + "--output", + "reports/evidence/run-123", + "--dry-run", + ]))); + assert.equal(result.code, 0); + assert.match(result.output, /^# Test Automation: pipeline-debug-chat/m); + assert.match(result.output, /scripts\/e2e\/pipeline-debug-chat\.mjs/); + assert.match(result.output, /console_log: reports\/evidence\/run-123\/console\.log/); + assert.match(result.output, /automation_result_json: reports\/evidence\/run-123\/automation-result\.json/); + assert.match(result.output, /result_json: reports\/evidence\/run-123\/result\.json/); + assert.match(result.output, /LANGBOT_PIPELINE_URL/); +}); + +test("test run dry-run JSON is parseable for automation orchestration", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "webui-login-state", + "--run-id", + "login-run", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.case.id, "webui-login-state"); + assert.equal(run.run_id, "login-run"); + assert.equal(run.automation.script, "scripts/e2e/webui-login-state.mjs"); + assert.equal(run.automation.exists, true); + assert.match(run.automation.automation_result_json, /automation-result\.json$/); + assert.match(run.automation.result_json, /result\.json$/); + assert.match(run.automation.report_command, /--console-log/); +}); + +test("test run JSON executes automation unless dry-run is explicit", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-json-exec-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "json-exec.yaml"), + [ + "id: json-exec", + "title: JSON Exec", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/write-marker.mjs", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-marker.mjs"), + [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "writeFileSync(join(process.env.LBS_ROOT, 'json-ran.txt'), 'yes');", + ].join("\n"), + ); + + const result = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "json-exec", "--run-id", "json-run", "--json"], + })); + + assert.equal(result.code, 0); + assert.equal(readFileSync(join(tmp, "json-ran.txt"), "utf8"), "yes"); + const payload = JSON.parse(result.output); + assert.equal(payload.exit_status, 0); + assert.equal(payload.automation_execution.status, "ok"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run lets explicit environment override automation defaults", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-env-override-")); + const originalPatch = process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON; + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "env-override.yaml"), + [ + "id: env-override", + "title: Env Override", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: false", + "automation: scripts/write-env.mjs", + "automation_runner_config_patch_json: '{\"source\":\"default\"}'", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-env.mjs"), + [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "writeFileSync(join(process.env.LBS_ROOT, 'env-out.json'), JSON.stringify({", + " patch: process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON,", + "}));", + ].join("\n"), + ); + + process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON = '{"source":"explicit"}'; + const result = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "env-override", "--run-id", "env-run"], + })); + + assert.equal(result.code, 0); + const observed = JSON.parse(readFileSync(join(tmp, "env-out.json"), "utf8")); + assert.equal(observed.patch, '{"source":"explicit"}'); + } finally { + if (originalPatch === undefined) delete process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON; + else process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON = originalPatch; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run expands env references in automation defaults", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-env-expand-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), "QA_KB_UUID=kb-from-env\nQA_MODEL_UUID=model-from-env\n"); + writeFileSync( + join(casesDir, "env-expand.yaml"), + [ + "id: env-expand", + "title: Env Expand", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: false", + "automation: scripts/write-expanded-env.mjs", + "automation_runner_config_patch_json: '{\"knowledge-bases\":[\"${QA_KB_UUID}\"],\"model\":{\"primary\":\"${QA_MODEL_UUID}\"}}'", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-expanded-env.mjs"), + [ + "import { writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "writeFileSync(join(process.env.LBS_ROOT, 'expanded-env-out.json'), JSON.stringify({", + " patch: process.env.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON,", + "}));", + ].join("\n"), + ); + + const dryRun = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "env-expand", "--run-id", "env-expand-dry", "--dry-run", "--json"], + })); + assert.equal(dryRun.code, 0); + const plan = JSON.parse(dryRun.output); + assert.equal( + plan.automation.env_defaults.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON, + '{"knowledge-bases":["kb-from-env"],"model":{"primary":"model-from-env"}}', + ); + + const run = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "env-expand", "--run-id", "env-expand-run"], + })); + assert.equal(run.code, 0); + const observed = JSON.parse(readFileSync(join(tmp, "expanded-env-out.json"), "utf8")); + assert.equal(observed.patch, '{"knowledge-bases":["kb-from-env"],"model":{"primary":"model-from-env"}}'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run setup automation isolates evidence and reloads env", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-setup-automation-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), "SETUP_VALUE=\n"); + writeFileSync( + join(casesDir, "setup-main.yaml"), + [ + "id: setup-main", + "title: Setup Main", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: false", + "env:", + " - SETUP_VALUE", + "setup_automation:", + " - \"node:scripts/write-setup-env.mjs --write-env\"", + "setup_provides_env:", + " - SETUP_VALUE", + "automation: scripts/read-setup-env.mjs", + ].join("\n"), + ); + writeFileSync( + join(casesDir, "setup-env-issue.yaml"), + [ + "id: setup-env-issue", + "title: Setup Env Issue", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: false", + "setup_automation:", + " - \"node:scripts/write-setup-env-issue.mjs\"", + "automation: scripts/read-setup-env.mjs", + ].join("\n"), + ); + writeFileSync( + join(casesDir, "setup-fail-after-pass.yaml"), + [ + "id: setup-fail-after-pass", + "title: Setup Fail After Pass", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: false", + "setup_automation:", + " - \"node:scripts/write-setup-pass-then-fail.mjs\"", + "automation: scripts/read-setup-env.mjs", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-setup-env.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { dirname, join } from 'node:path';", + "const local = join(process.env.LBS_ROOT, 'skills', '.env.local');", + "writeFileSync(local, 'SETUP_VALUE=from-setup\\n');", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass', stage: 'setup' }));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ status: 'pass', stage: 'setup' }));", + "writeFileSync(join(dirname(process.env.LBS_EVIDENCE_DIR), 'setup-ran.txt'), 'yes');", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "read-setup-env.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_ROOT, 'main-observed.json'), JSON.stringify({ value: process.env.SETUP_VALUE }));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass', stage: 'main' }));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ status: 'pass', stage: 'main' }));", + "if (process.env.SETUP_VALUE !== 'from-setup') process.exit(1);", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-setup-env-issue.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'env_issue', reason: 'setup env missing' }));", + "process.exit(2);", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-setup-pass-then-fail.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass', reason: 'stale pass before crash' }));", + "process.exit(1);", + ].join("\n"), + ); + + const dryRun = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-main", "--run-id", "setup-run", "--output", join(tmp, "evidence"), "--dry-run", "--json"], + })); + assert.equal(dryRun.code, 0); + const plan = JSON.parse(dryRun.output); + assert.equal(plan.setup_automation.length, 1); + assert.match(plan.setup_automation[0].evidence_dir, /setup\/01-write-setup-env$/); + assert.match(plan.setup_automation[0].command, /^node scripts\/write-setup-env\.mjs --write-env$/); + assert.equal(plan.setup_automation[0].dry_run_command, ""); + assert.equal(existsSync(join(tmp, "skills", ".env.local")), false); + + const run = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-main", "--run-id", "setup-run", "--output", join(tmp, "evidence")], + })); + assert.equal(run.code, 0); + const observed = JSON.parse(readFileSync(join(tmp, "main-observed.json"), "utf8")); + assert.equal(observed.value, "from-setup"); + const setupResult = JSON.parse(readFileSync(join(tmp, "evidence", "setup", "01-write-setup-env", "automation-result.json"), "utf8")); + const mainResult = JSON.parse(readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8")); + assert.equal(setupResult.stage, "setup"); + assert.equal(mainResult.stage, "main"); + + const envIssue = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-env-issue", "--run-id", "setup-env-issue-run", "--output", join(tmp, "evidence-env-issue")], + })); + assert.equal(envIssue.code, 2); + const parentResult = JSON.parse(readFileSync(join(tmp, "evidence-env-issue", "automation-result.json"), "utf8")); + assert.equal(parentResult.status, "env_issue"); + assert.equal(parentResult.reason, "setup env missing"); + + const failAfterPass = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-fail-after-pass", "--run-id", "setup-fail-after-pass-run", "--output", join(tmp, "evidence-fail-after-pass")], + })); + assert.equal(failAfterPass.code, 1); + const failAfterPassResult = JSON.parse(readFileSync(join(tmp, "evidence-fail-after-pass", "automation-result.json"), "utf8")); + assert.equal(failAfterPassResult.status, "fail"); + assert.equal(failAfterPassResult.reason, "stale pass before crash"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run setup automation can execute another case outside this source repo", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-setup-case-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), "SETUP_VALUE=\n"); + writeFileSync( + join(casesDir, "setup-child.yaml"), + [ + "id: setup-child", + "title: Setup Child", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/write-child-env.mjs", + ].join("\n"), + ); + writeFileSync( + join(casesDir, "setup-parent.yaml"), + [ + "id: setup-parent", + "title: Setup Parent", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "setup_automation:", + " - \"case:setup-child\"", + "setup_provides_env:", + " - SETUP_VALUE", + "automation: scripts/read-child-env.mjs", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "write-child-env.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "writeFileSync(join(process.env.LBS_ROOT, 'skills', '.env.local'), 'SETUP_VALUE=from-child\\n');", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass' }));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ status: 'pass' }));", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "read-child-env.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: 'pass', value: process.env.SETUP_VALUE }));", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify({ status: 'pass', value: process.env.SETUP_VALUE }));", + "if (process.env.SETUP_VALUE !== 'from-child') process.exit(1);", + ].join("\n"), + ); + + const run = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-parent", "--run-id", "setup-parent-run", "--output", join(tmp, "evidence")], + })); + + assert.equal(run.code, 0); + assert.ok(existsSync(join(tmp, "evidence", "setup", "01-setup-child", "result.json"))); + const result = JSON.parse(readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8")); + assert.equal(result.value, "from-child"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run automation inherits parent process environment", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-env-inherit-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "env-inherit.yaml"), + [ + "id: env-inherit", + "title: Env Inherit", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "automation: scripts/read-path.mjs", + ].join("\n"), + ); + writeFileSync( + join(scriptsDir, "read-path.mjs"), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + "mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });", + "writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ status: process.env.PATH ? 'pass' : 'fail' }));", + "process.exit(process.env.PATH ? 0 : 1);", + ].join("\n"), + ); + + const run = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "env-inherit", "--run-id", "env-inherit-run", "--output", join(tmp, "evidence")], + })); + + assert.equal(run.code, 0); + const result = JSON.parse(readFileSync(join(tmp, "evidence", "automation-result.json"), "utf8")); + assert.equal(result.status, "pass"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test run dry-run marks missing setup case targets", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-run-setup-missing-case-")); + try { + const skillDir = join(tmp, "skills", "langbot-testing"); + const casesDir = join(skillDir, "cases"); + const scriptsDir = join(tmp, "scripts"); + mkdirSync(casesDir, { recursive: true }); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync(join(tmp, "skills", ".env"), ""); + writeFileSync( + join(casesDir, "setup-parent.yaml"), + [ + "id: setup-parent", + "title: Setup Parent", + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "setup_automation:", + " - \"case:missing-child\"", + "automation: scripts/pass.mjs", + ].join("\n"), + ); + writeFileSync(join(scriptsDir, "pass.mjs"), "process.exit(0);\n"); + + const result = capture(() => commandTestRun({ + root: tmp, + args: ["test", "run", "setup-parent", "--dry-run", "--json"], + })); + + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.setup_automation[0].entry, "case:missing-child"); + assert.doesNotMatch(run.setup_automation[0].command, /--dry-run/); + assert.match(run.setup_automation[0].dry_run_command, /--dry-run/); + assert.equal(run.setup_automation[0].exists, false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("local-agent effective prompt case has runnable automation defaults", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-effective-prompt-debug-chat", + "--run-id", + "effective-run", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_PROMPT, "qa-effective-prompt"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, "PROMPT_PREPROCESS_OK"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_RESPONSE_TIMEOUT_MS, "180000"); + assert.equal(run.automation.pipeline_env_required, true); + assert.ok(run.automation.env_aliases.some((alias: { target: string; source: string }) => ( + alias.target === "LANGBOT_E2E_PIPELINE_URL" && alias.source === "LANGBOT_LOCAL_AGENT_PIPELINE_URL" + ))); +}); + +test("local-agent basic case can setup the local-agent pipeline env", () => { + withEnv({ + LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", + LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", + }, () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-basic-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + ]); + + const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "local-agent-basic-debug-chat", "--json"]))); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + ]); + assert.equal(plan.automation_readiness.status, "ready"); + }); +}); + +test("local-agent nonstreaming case disables stream output through automation defaults", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-nonstreaming-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_PROMPT, "Reply only NONSTREAM_OK."); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, "NONSTREAM_OK"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_STREAM_OUTPUT, "0"); + assert.equal(run.automation.pipeline_env_required, true); +}); + +test("local-agent multimodal case exposes image fixture automation defaults", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-multimodal-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_TEXT, "IMAGE_OK"); + assert.match(run.automation.env_defaults.LANGBOT_E2E_IMAGE_BASE64_PATH, /red-square\.png\.base64$/); + assert.equal(run.automation.pipeline_env_required, true); +}); + +test("MCP stdio case passes case-specific failure signals to automation defaults", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "mcp-stdio-tool-call", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.match(run.automation.env_defaults.LANGBOT_E2E_FAILURE_SIGNALS, /qa-plugin-smoke:mcp-ok-local-agent/); + assert.match(run.automation.env_defaults.LANGBOT_E2E_FAILURE_SIGNALS, /model_not_found/); +}); + +test("MCP stdio tool-call case setups pipeline and registered MCP server", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "mcp-stdio-tool-call", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:mcp-stdio-register", + ]); + + const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "mcp-stdio-tool-call", "--json"]))); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_LOCAL_AGENT_PIPELINE_URL", + "LANGBOT_LOCAL_AGENT_PIPELINE_NAME", + ]); + assert.ok(!plan.preconditions.some((item: string) => item.includes("points to the local-agent pipeline"))); +}); + +test("generic pipeline automation can still use the shared pipeline env", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "pipeline-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.automation.pipeline_env_required, false); + assert.deepEqual(run.automation.env_aliases, []); + assert.ok(run.automation.required_env.includes("LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME")); +}); + +test("AgentRunner live install case exposes package automation defaults", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "agent-runner-live-install", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_PLUGIN_PACKAGE, + "skills/langbot-testing/fixtures/plugins/qa-agent-runner/dist/qa-agent-runner-0.1.0.lbpkg", + ); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_PLUGIN_ID, "qa/agent-runner"); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID, "plugin:qa/agent-runner/default"); +}); + +test("QA plugin live install checks the fixture package before installed state", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-install-qa-plugin-")); + try { + const result = spawnSync( + process.execPath, + [join(root, "scripts/e2e/install-qa-plugin-smoke.mjs")], + { + cwd: root, + env: { + ...process.env, + LBS_RUN_ID: "missing-package", + LBS_EVIDENCE_DIR: join(tmp, "evidence"), + LANGBOT_BACKEND_URL: "http://127.0.0.1:59999", + LANGBOT_E2E_LOGIN_USER: "qa@example.test", + LANGBOT_E2E_PLUGIN_PACKAGE: join(tmp, "missing.lbpkg"), + }, + encoding: "utf8", + }, + ); + assert.equal(result.status, 1); + const output = JSON.parse(result.stdout); + assert.match(output.reason, /missing\.lbpkg/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("AgentRunner QA Debug Chat case uses dedicated pipeline env", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "agent-runner-qa-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.equal(run.automation.script, "scripts/e2e/pipeline-debug-chat.mjs"); + assert.equal(run.automation.pipeline_env_required, true); + assert.equal(run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID, "plugin:qa/agent-runner/default"); + assert.deepEqual( + run.setup_automation.map((item: { entry: string }) => item.entry), + [ + "case:agent-runner-live-install", + "node:scripts/e2e/ensure-qa-agent-runner-pipeline.mjs --write-env", + ], + ); + assert.ok(run.automation.env_aliases.some((alias: { target: string; source: string }) => ( + alias.target === "LANGBOT_E2E_PIPELINE_URL" && alias.source === "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL" + ))); +}); + +test("AgentRunner QA Debug Chat setup automation removes manual readiness", () => { + withEnv({ + LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", + LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", + }, () => { + const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "agent-runner-qa-debug-chat", "--json"]))); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.equal(plan.manual_readiness.status, "not_required"); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_URL", + "LANGBOT_QA_AGENT_RUNNER_PIPELINE_NAME", + ]); + assert.equal(plan.automation_readiness.status, "ready"); + + const suiteResult = capture(() => commandSuitePlan(ctx(["suite", "plan", "agent-runner-release-gate", "--json"]))); + assert.equal(suiteResult.code, 0); + const suite = JSON.parse(suiteResult.output); + assert.ok(!suite.readiness.manual_check_cases.includes("agent-runner-qa-debug-chat")); + }); +}); + +test("ACP AgentRunner Debug Chat case setups the ACP pipeline env", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "acp-agent-runner-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ + "node:scripts/e2e/ensure-acp-agent-runner-pipeline.mjs --write-env", + ]); + assert.ok(run.automation.env_aliases.some((alias: { target: string; source: string }) => ( + alias.target === "LANGBOT_E2E_PIPELINE_URL" && alias.source === "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL" + ))); + + const planResult = capture(() => commandTestPlan(ctx(["test", "plan", "acp-agent-runner-debug-chat", "--json"]))); + assert.equal(planResult.code, 0); + const plan = JSON.parse(planResult.output); + assert.deepEqual(plan.setup_provides_env, [ + "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_URL", + "LANGBOT_ACP_AGENT_RUNNER_PIPELINE_NAME", + ]); + assert.ok(!plan.preconditions.some((item: string) => item.includes("pipeline AI runner"))); +}); + +test("local-agent plugin cases setup the QA plugin smoke fixture", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-plugin-tool-call-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "case:qa-plugin-smoke-live-install", + ]); +}); + +test("local-agent RAG case only requires the KB fixture env", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-rag-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.ok(run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID")); + assert.ok(!run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_TEXT_MODEL_UUID")); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON, + JSON.stringify({ + "knowledge-bases": [ + loadEnv(root).LANGBOT_LOCAL_AGENT_RAG_KB_UUID || "", + ], + }), + ); +}); + +test("LangRAG retrieve readiness requires a KB UUID alternative", () => { + const result = capture(() => commandTestPlan(ctx(["test", "plan", "langrag-kb-retrieve", "--json"]))); + assert.equal(result.code, 0); + const plan = JSON.parse(result.output); + assert.ok(plan.automation_readiness.required.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID|LANGBOT_RAG_KB_UUID")); +}); + +test("local-agent RAG multimodal case setups the KB fixture env", () => { + const result = capture(() => commandTestRun(ctx([ + "test", + "run", + "local-agent-rag-multimodal-debug-chat", + "--dry-run", + "--json", + ]))); + assert.equal(result.code, 0); + const run = JSON.parse(result.output); + assert.ok(run.automation.required_env.includes("LANGBOT_LOCAL_AGENT_RAG_KB_UUID")); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_RUNNER_CONFIG_PATCH_JSON, + JSON.stringify({ + "knowledge-bases": [ + loadEnv(root).LANGBOT_LOCAL_AGENT_RAG_KB_UUID || "", + ], + }), + ); + assert.deepEqual(run.setup_automation.map((item: { entry: string }) => item.entry), [ + "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env", + ]); +}); + +test("test report renders a reusable evidence template", () => { + const result = capture(() => commandTestReport(ctx(["test", "report", "pipeline-debug-chat", "--no-auto-log"]))); + assert.equal(result.code, 0); + assert.match(result.output, /^# Test Report: pipeline-debug-chat/m); + assert.match(result.output, /result: pass \| fail \| blocked \| env_issue \| flaky/); + assert.match(result.output, /## Log Guard/); + assert.match(result.output, /## Automation Result/); + assert.match(result.output, /## Required Evidence/); + assert.match(result.output, /no log files provided/); +}); + +test("test report promotes loaded automation evidence into result section", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-automation-")); + try { + writeFileSync( + join(tmp, "automation-result.json"), + JSON.stringify({ + status: "pass", + reason: "latency thresholds passed", + url: "http://127.0.0.1:5300", + artifacts: { metrics_json: join(tmp, "metrics.json") }, + }), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "langbot-live-backend-latency", + "--evidence-dir", + tmp, + "--no-auto-log", + ]))); + + assert.equal(result.code, 0); + assert.match(result.output, /## Result\n- result: pass\n- reason: latency thresholds passed/); + assert.match(result.output, /- target_tested: http:\/\/127\.0\.0\.1:5300/); + assert.doesNotMatch(result.output, /target_tested: TODO/); + assert.match(result.output, /## Automation Result/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("validate rejects dangling case references and missing automation scripts", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-validate-strict-")); + try { + const schemasDir = join(tmp, "schemas"); + const skillsDir = join(tmp, "skills"); + const envSetupDir = join(skillsDir, "langbot-env-setup"); + const testingDir = join(skillsDir, "langbot-testing"); + mkdirSync(schemasDir, { recursive: true }); + mkdirSync(join(testingDir, "cases"), { recursive: true }); + mkdirSync(join(testingDir, "fixtures"), { recursive: true }); + mkdirSync(join(testingDir, "suites"), { recursive: true }); + mkdirSync(envSetupDir, { recursive: true }); + for (const schemaName of ["case.schema.json", "suite.schema.json", "troubleshooting.schema.json", "skill-index.schema.json"]) { + writeFileSync(join(schemasDir, schemaName), "{}"); + } + writeFileSync(join(envSetupDir, "SKILL.md"), "---\nname: langbot-env-setup\ndescription: Env setup.\n---\n\n# Env\n"); + writeFileSync(join(testingDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n"); + writeFileSync( + join(skillsDir, ".env"), + [ + "LANGBOT_FRONTEND_URL=http://127.0.0.1:3000", + "LANGBOT_BACKEND_URL=http://127.0.0.1:5300", + "LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:3000", + "LANGBOT_REPO=/tmp/langbot", + "LANGBOT_WEB_REPO=/tmp/langbot/web", + "LANGBOT_BROWSER_PROFILE=/tmp/browser", + "LANGBOT_CHROMIUM_EXECUTABLE=/tmp/chromium", + "LANGBOT_PROXY_HTTP=http://127.0.0.1:7890", + "LANGBOT_PROXY_SOCKS=socks5://127.0.0.1:7890", + "LANGBOT_NO_PROXY=localhost,127.0.0.1,::1", + ].join("\n"), + ); + writeFileSync( + join(testingDir, "cases", "bad.yaml"), + [ + "id: bad", + "title: Bad", + "mode: agent-browser", + "area: pipeline", + "type: smoke", + "priority: p9", + "risk: medium", + "ci_eligible: false", + "tags:", + " - smoke", + "skills:", + " - langbot-env-setup", + " - langbot-testing", + "env:", + " - LANGBOT_FRONTEND_URL", + "automation: scripts/e2e/missing.mjs", + "setup_provides_env:", + " - LANGBOT_PIPELINE_URL", + "steps:", + " - Open UI.", + "checks:", + " - UI works.", + "evidence_required:", + " - ui", + "troubleshooting:", + " - missing-trouble", + ].join("\n"), + ); + for (const [id, target] of [["cycle-a", "cycle-b"], ["cycle-b", "cycle-a"]]) { + writeFileSync( + join(testingDir, "cases", `${id}.yaml`), + [ + `id: ${id}`, + `title: ${id}`, + "mode: probe", + "area: qa", + "type: smoke", + "priority: p2", + "risk: low", + "ci_eligible: true", + "tags:", + " - smoke", + "skills:", + " - langbot-testing", + "setup_automation:", + ` - \"case:${target}\"`, + "steps:", + " - Run probe.", + "checks:", + " - Probe works.", + "evidence_required:", + " - filesystem", + ].join("\n"), + ); + } + writeFileSync( + join(testingDir, "suites", "bad-suite.yaml"), + [ + "id: bad-suite", + "title: Bad Suite", + "description: Bad suite for strict validation.", + "type: release_gate", + "priority: p1", + "tags:", + " - gate", + "cases:", + " - missing-case", + ].join("\n"), + ); + writeFileSync( + join(testingDir, "fixtures", "fixtures.json"), + JSON.stringify([{ id: "bad-fixture", title: "Bad Fixture", path: "fixtures/missing.txt", related_cases: ["missing-case"] }]), + ); + + const result = captureAll(() => commandValidate(tmp)); + + assert.equal(result.code, 1); + assert.match(result.error, /priority/); + assert.match(result.error, /missing-trouble/); + assert.match(result.error, /missing-case/); + assert.match(result.error, /bad-fixture/); + assert.match(result.error, /automation script does not exist/); + assert.match(result.error, /setup_provides_env/); + assert.match(result.error, /setup_automation case cycle detected/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report JSON scans logs and redacts secrets", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "INFO request started", + "Action invoke_llm_stream call timed out", + "Traceback (most recent call last):", + "API_KEY=sk-test-secret", + ].join("\n"), + ); + + const result = capture(() => commandTestReport(ctx(["test", "report", "pipeline-debug-chat", "--backend-log", logPath, "--json"]))); + assert.equal(result.code, 0); + assert.doesNotMatch(result.output, /sk-test-secret/); + + const report = JSON.parse(result.output); + assert.equal(report.log_guard.status, "fail"); + assert.ok(report.log_guard.findings.some((finding: { kind: string }) => ( + finding.kind === "case_failure_pattern" + ))); + assert.ok(report.log_guard.findings.some((finding: { troubleshooting_id?: string }) => ( + finding.troubleshooting_id === "plugin-runtime-timeout" + ))); + assert.ok(report.log_guard.findings.some((finding: { kind: string }) => finding.kind === "python_traceback")); + + const secretFinding = report.log_guard.findings.find((finding: { kind: string }) => finding.kind === "secret_leak"); + assert.ok(secretFinding); + assert.match(secretFinding.excerpt, /\[redacted\]/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report does not treat invalid api key wording as a secret leak", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-api-key-wording-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + "RequesterError: 模型请求失败: 无效的 api-key: Error code: 401 - invalid api key\n", + ); + + const result = capture(() => commandTestReport(ctx(["test", "report", "mcp-stdio-tool-call", "--backend-log", logPath, "--json"]))); + assert.equal(result.code, 0); + assert.match(result.output, /api-key: Error code/); + + const report = JSON.parse(result.output); + assert.ok(!report.log_guard.findings.some((finding: { kind: string }) => finding.kind === "secret_leak")); + assert.ok(report.log_guard.findings.some((finding: { troubleshooting_id?: string }) => ( + finding.troubleshooting_id === "local-agent-model-route-unavailable" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report records declared success signals from logs", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-success-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "[05-21 10:31:00.000] websocket.py (1) - [INFO] : Processing request from person_websocket", + "[05-21 10:31:01.000] runner.py (2) - [INFO] : Conversation(0) Streaming completed", + ].join("\n"), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.status, "pass"); + assert.equal(report.log_guard.success_signals.length, 2); + assert.ok(report.log_guard.success_signals.some((signal: { pattern: string }) => ( + signal.pattern === "Streaming completed" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report warns when declared success signals are missing", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-missing-success-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync(logPath, "INFO request started\nINFO request ended\n"); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.status, "warning"); + assert.ok(report.log_guard.findings.some((finding: { kind: string }) => ( + finding.kind === "missing_success_signal" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report can limit log guard to tail lines", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-tail-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "ERROR old failure outside scan window", + "INFO middle", + "Action invoke_llm_stream call timed out", + "API_KEY=sk-tail-secret", + ].join("\n"), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--tail-lines", + "2", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.scan.mode, "tail-lines"); + assert.equal(report.log_guard.scan.tail_lines, 2); + assert.equal(report.log_guard.sources[0].line_count, 2); + assert.equal(report.log_guard.sources[0].start_line, 3); + assert.ok(report.log_guard.findings.some((finding: { troubleshooting_id?: string }) => ( + finding.troubleshooting_id === "plugin-runtime-timeout" + ))); + assert.ok(!report.log_guard.findings.some((finding: { kind: string; excerpt?: string }) => ( + finding.kind === "error_log" && finding.excerpt?.includes("old failure") + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report can limit log guard with since timestamp", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-since-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "[05-21 09:59:00.000] old.py (1) - [ERROR] : old failure outside scan window", + "[05-21 10:31:00.000] runner.py (2) - [ERROR] : Action invoke_llm_stream call timed out", + "Traceback continuation should stay with the matching timestamp block", + "[05-21 10:32:00.000] secrets.py (3) - [INFO] : API_KEY=sk-since-secret", + ].join("\n"), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--since", + "2026-05-21T10:30:00+08:00", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.scan.mode, "since"); + assert.equal(report.log_guard.sources[0].line_count, 3); + assert.equal(report.log_guard.sources[0].start_line, 2); + assert.equal(report.log_guard.sources[0].timestamped_line_count, 3); + assert.ok(report.log_guard.findings.some((finding: { line?: number; troubleshooting_id?: string }) => ( + finding.line === 2 && finding.troubleshooting_id === "plugin-runtime-timeout" + ))); + assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("old failure"))); + assert.doesNotMatch(result.output, /sk-since-secret/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report can limit log guard with since and until timestamps", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-window-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "[05-21 10:29:59.000] old.py (1) - [ERROR] : old failure outside scan window", + "[05-21 10:31:00.000] runner.py (2) - [INFO] : Processing request from person_websocket", + "[05-21 10:31:01.000] runner.py (3) - [INFO] : Conversation(0) Streaming completed", + "[05-21 10:32:01.000] later.py (4) - [ERROR] : later failure outside scan window", + ].join("\n"), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--backend-log", + logPath, + "--since", + "2026-05-21T10:30:00+08:00", + "--until", + "2026-05-21T10:32:00+08:00", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.scan.mode, "since+until"); + assert.equal(report.log_guard.sources[0].line_count, 2); + assert.equal(report.log_guard.sources[0].start_line, 2); + assert.equal(report.log_guard.sources[0].end_line, 3); + assert.equal(report.log_guard.status, "pass"); + assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("old failure"))); + assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("later failure"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report classifies model route failures as env_issue", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-env-issue-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + "[05-21 10:31:00.000] runner.py (2) - [ERROR] : runner.llm_error model_not_found no available channel for model gpt-test\n", + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "local-agent-plugin-tool-call-debug-chat", + "--backend-log", + logPath, + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.status, "env_issue"); + assert.ok(report.log_guard.findings.some((finding: { severity?: string; troubleshooting_id?: string }) => ( + finding.severity === "env_issue" && finding.troubleshooting_id === "local-agent-model-route-unavailable" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report infers scan window from automation result evidence", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-evidence-window-")); + try { + const evidenceDir = join(tmp, "evidence", "run-123"); + mkdirSync(evidenceDir, { recursive: true }); + const consoleLog = join(evidenceDir, "console.log"); + writeFileSync( + consoleLog, + [ + "[05-21 10:29:59.000] old.js (1) - [ERROR] : old failure outside scan window", + "[05-21 10:31:00.000] runner.js (2) - [INFO] : Processing request from person_websocket", + "[05-21 10:31:01.000] runner.js (3) - [INFO] : Conversation(0) Streaming completed", + "[05-21 10:32:01.000] later.js (4) - [ERROR] : later failure outside scan window", + ].join("\n"), + ); + writeFileSync( + join(evidenceDir, "automation-result.json"), + JSON.stringify({ + source: "automation", + status: "pass", + reason: "UI sentinel appeared.", + started_at_local: "2026-05-21T10:30:00.000+08:00", + finished_at_local: "2026-05-21T10:32:00.000+08:00", + }), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "pipeline-debug-chat", + "--console-log", + consoleLog, + "--no-auto-log", + "--json", + ]))); + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.scan.mode, "since+until"); + assert.equal(report.log_guard.scan.since, "2026-05-21T10:30:00.000+08:00"); + assert.equal(report.log_guard.scan.until, "2026-05-21T10:32:00.000+08:00"); + assert.equal(report.log_guard.sources[0].line_count, 2); + assert.equal(report.log_guard.status, "pass"); + assert.equal(report.automation_result.status, "loaded"); + assert.equal(report.automation_result.result, "pass"); + assert.equal(report.automation_result.reason, "UI sentinel appeared."); + assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("old failure"))); + assert.ok(!report.log_guard.findings.some((finding: { excerpt?: string }) => finding.excerpt?.includes("later failure"))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report does not treat final result as automation evidence", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-final-result-")); + try { + const evidenceDir = join(tmp, "evidence", "run-final"); + mkdirSync(evidenceDir, { recursive: true }); + const consoleLog = join(evidenceDir, "console.log"); + writeFileSync(consoleLog, "[05-21 10:31:00.000] ui.js (1) - [INFO] : opened\n"); + writeFileSync( + join(evidenceDir, "result.json"), + JSON.stringify({ + source: "final", + status: "pass", + reason: "Final manual decision.", + started_at_local: "2026-05-21T10:30:00.000+08:00", + finished_at_local: "2026-05-21T10:32:00.000+08:00", + evidence_collected: ["ui", "screenshot", "console"], + }), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "webui-login-state", + "--console-log", + consoleLog, + "--no-auto-log", + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.automation_result.status, "not_provided"); + assert.match(report.automation_result.reason, /only final result\.json is present/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report still scans untimestamped explicit console evidence within an inferred run window", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-untimestamped-console-")); + try { + const evidenceDir = join(tmp, "evidence", "run-untimestamped"); + mkdirSync(evidenceDir, { recursive: true }); + const consoleLog = join(evidenceDir, "console.log"); + writeFileSync(consoleLog, "[error] Uncaught TypeError: Cannot read properties of undefined\n"); + writeFileSync( + join(evidenceDir, "result.json"), + JSON.stringify({ + status: "pass", + reason: "UI sentinel appeared.", + started_at_local: "2026-05-21T10:30:00.000+08:00", + finished_at_local: "2026-05-21T10:32:00.000+08:00", + }), + ); + + const result = capture(() => commandTestReport(ctx([ + "test", + "report", + "webui-login-state", + "--console-log", + consoleLog, + "--no-auto-log", + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.log_guard.scan.mode, "since+until"); + assert.equal(report.log_guard.sources[0].timestamped_line_count, 0); + assert.ok(report.log_guard.sources[0].line_count >= 1); + assert.equal(report.log_guard.status, "fail"); + assert.ok(report.log_guard.findings.some((finding: { kind: string }) => ( + finding.kind === "frontend_uncaught_error" + ))); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("test report can write markdown to an output path", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-report-output-")); + try { + const output = join(tmp, "reports", "pipeline-debug-chat.md"); + const result = capture(() => commandTestReport(ctx(["test", "report", "pipeline-debug-chat", "--output", output]))); + assert.equal(result.code, 0); + assert.match(result.output, /pipeline-debug-chat\.md$/); + assert.match(readFileSync(output, "utf8"), /^# Test Report: pipeline-debug-chat/m); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("log scan reuses case-aware log guard patterns", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-log-scan-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync( + logPath, + [ + "[05-21 10:31:00.000] process.py (1) - [INFO] : Processing request from person_websocket", + "[05-21 10:31:01.000] chat.py (2) - [INFO] : Conversation(0) Streaming completed", + "[05-21 10:31:02.000] runner.py (3) - [ERROR] : Action invoke_llm_stream call timed out", + ].join("\n"), + ); + + const result = capture(() => commandLogScan(ctx([ + "log", + "scan", + "--backend-log", + logPath, + "--case", + "pipeline-debug-chat", + "--json", + ]))); + + assert.equal(result.code, 0); + const report = JSON.parse(result.output); + assert.equal(report.status, "fail"); + assert.ok(report.success_signals.some((signal: { pattern: string }) => signal.pattern === "Streaming completed")); + assert.ok(report.findings.some((finding: { kind: string }) => finding.kind === "case_failure_pattern")); + + const strict = capture(() => commandLogScan(ctx([ + "log", + "scan", + "--backend-log", + logPath, + "--case", + "pipeline-debug-chat", + "--strict", + "--json", + ]))); + assert.equal(strict.code, 1); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("log guard start and stop bound a QA log window", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-log-guard-")); + try { + const logPath = join(tmp, "backend.log"); + const outputDir = join(tmp, "guards"); + writeFileSync(logPath, "INFO before guard\n"); + + const start = capture(() => commandLogGuard(ctx([ + "log", + "guard", + "start", + "--run-id", + "qa-run", + "--output-dir", + outputDir, + "--backend-log", + logPath, + "--case", + "pipeline-debug-chat", + "--json", + ]))); + assert.equal(start.code, 0); + const session = JSON.parse(start.output); + assert.equal(session.run_id, "qa-run"); + assert.ok(existsSync(join(outputDir, "qa-run.json"))); + + appendFileSync(logPath, "Traceback (most recent call last):\n"); + const stop = capture(() => commandLogGuard(ctx([ + "log", + "guard", + "stop", + "--run-id", + "qa-run", + "--output-dir", + outputDir, + "--json", + ]))); + + assert.equal(stop.code, 1); + const report = JSON.parse(stop.output); + assert.equal(report.session.run_id, "qa-run"); + assert.equal(report.result.status, "fail"); + assert.ok(report.result.findings.some((finding: { kind: string }) => finding.kind === "python_traceback")); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("log watch observes appended LangBot backend lines", async () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-log-watch-")); + try { + const logPath = join(tmp, "backend.log"); + writeFileSync(logPath, "INFO existing line\n"); + + const watching = captureAsync(() => commandLogWatch(ctx([ + "log", + "watch", + "--backend-log", + logPath, + "--duration-ms", + "220", + "--interval-ms", + "20", + "--strict", + "--json", + ]))); + setTimeout(() => { + appendFileSync(logPath, "Traceback (most recent call last):\n"); + }, 50); + + const result = await watching; + assert.equal(result.code, 1); + const summary = JSON.parse(result.output); + assert.equal(summary.mode, "watch"); + assert.equal(summary.status, "fail"); + assert.ok(summary.bytes_read > 0); + assert.ok(summary.findings.some((finding: { kind: string }) => finding.kind === "python_traceback")); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("trouble search finds structured troubleshooting entries", () => { + const result = capture(() => commandTroubleSearch(ctx(["trouble", "search", "proxy"]))); + assert.equal(result.code, 0); + assert.match(result.output, /proxy-env-mismatch/); +}); + +test("env local overrides shared env defaults", () => { + const tmp = mkdtempSync(join(tmpdir(), "lbs-env-")); + try { + mkdirSync(join(tmp, "skills")); + writeFileSync(join(tmp, "skills", ".env"), "LANGBOT_REPO=/shared\nLANGBOT_BACKEND_URL=http://127.0.0.1:5300\n"); + writeFileSync(join(tmp, "skills", ".env.local"), "LANGBOT_REPO=/local\n"); + + assert.deepEqual(loadEnv(tmp), { + LANGBOT_REPO: "/local", + LANGBOT_BACKEND_URL: "http://127.0.0.1:5300", + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/langbot/__init__.py b/src/langbot/__init__.py new file mode 100644 index 0000000..272d8db --- /dev/null +++ b/src/langbot/__init__.py @@ -0,0 +1,5 @@ +"""LangBot - Production-grade platform for building agentic IM bots""" + +from importlib.metadata import version + +__version__ = version('langbot') diff --git a/src/langbot/__main__.py b/src/langbot/__main__.py new file mode 100644 index 0000000..4855982 --- /dev/null +++ b/src/langbot/__main__.py @@ -0,0 +1,117 @@ +"""LangBot entry point for package execution""" + +import asyncio +import argparse +import sys +import os + +from langbot.pkg.utils import paths + +# ASCII art banner +asciiart = r""" + _ ___ _ +| | __ _ _ _ __ _| _ ) ___| |_ +| |__/ _` | ' \/ _` | _ \/ _ \ _| +|____\__,_|_||_\__, |___/\___/\__| + |___/ + +⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot +📖 Documentation 文档地址: https://docs.langbot.app +""" + + +async def main_entry(loop: asyncio.AbstractEventLoop): + """Main entry point for LangBot""" + parser = argparse.ArgumentParser(description='LangBot') + parser.add_argument( + '--standalone-runtime', + action='store_true', + help='Use standalone plugin runtime / 使用独立插件运行时', + default=False, + ) + parser.add_argument( + '--standalone-box', + action='store_true', + help='Use standalone box runtime / 使用独立 Box 运行时', + default=False, + ) + parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False) + args = parser.parse_args() + + if args.standalone_runtime: + from langbot.pkg.utils import platform + + platform.standalone_runtime = True + + if args.standalone_box: + from langbot.pkg.utils import platform + + platform.standalone_box = True + + if args.debug: + from langbot.pkg.utils import constants + + constants.debug_mode = True + + print(asciiart) + + # Check dependencies + from langbot.pkg.core.bootutils import deps + + missing_deps = await deps.check_deps() + + if missing_deps: + print('以下依赖包未安装,将自动安装,请完成后重启程序:') + print( + 'These dependencies are missing, they will be installed automatically, please restart the program after completion:' + ) + for dep in missing_deps: + print('-', dep) + await deps.install_deps(missing_deps) + print('已自动安装缺失的依赖包,请重启程序。') + print('The missing dependencies have been installed automatically, please restart the program.') + sys.exit(0) + + # Check configuration files + from langbot.pkg.core.bootutils import files + + generated_files = await files.generate_files() + + if generated_files: + print('以下文件不存在,已自动生成:') + print('Following files do not exist and have been automatically generated:') + for file in generated_files: + print('-', file) + + from langbot.pkg.core import boot + + await boot.main(loop) + + +def main(): + """Main function to be called by console script entry point""" + # Check Python version + if sys.version_info < (3, 10, 1): + print('需要 Python 3.10.1 及以上版本,当前 Python 版本为:', sys.version) + print('Your Python version is not supported.') + print('Python 3.10.1 or higher is required. Current version:', sys.version) + sys.exit(1) + + # Set up the working directory + # When installed as a package, we need to handle the working directory differently + # We'll create data directory in current working directory if not exists + os.makedirs(paths.get_data_root(), exist_ok=True) + + loop = asyncio.new_event_loop() + + try: + loop.run_until_complete(main_entry(loop)) + except KeyboardInterrupt: + print('\n正在退出...') + print('Exiting...') + finally: + loop.close() + + +if __name__ == '__main__': + main() diff --git a/src/langbot/libs/LICENSE b/src/langbot/libs/LICENSE new file mode 100644 index 0000000..e72bfdd --- /dev/null +++ b/src/langbot/libs/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/src/langbot/libs/README.md b/src/langbot/libs/README.md new file mode 100644 index 0000000..eece6f8 --- /dev/null +++ b/src/langbot/libs/README.md @@ -0,0 +1,4 @@ +# LangBot/libs + +LangBot 项目下的 libs 目录下的所有代码均遵循本目录下的许可证约束。 +您在使用、修改、分发本目录下的代码时,需要遵守其中包含的条款。 diff --git a/src/langbot/libs/coze_server_api/__init__.py b/src/langbot/libs/coze_server_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/libs/coze_server_api/client.py b/src/langbot/libs/coze_server_api/client.py new file mode 100644 index 0000000..54fb474 --- /dev/null +++ b/src/langbot/libs/coze_server_api/client.py @@ -0,0 +1,177 @@ +import json +import asyncio +import aiohttp +import io +from typing import Dict, List, Any, AsyncGenerator +import os +from pathlib import Path + + +class AsyncCozeAPIClient: + def __init__(self, api_key: str, api_base: str = 'https://api.coze.cn'): + self.api_key = api_key + self.api_base = api_base + self.session = None + + async def __aenter__(self): + """支持异步上下文管理器""" + await self.coze_session() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """退出时自动关闭会话""" + await self.close() + + async def coze_session(self): + """确保HTTP session存在""" + if self.session is None: + connector = aiohttp.TCPConnector( + ssl=False if self.api_base.startswith('http://') else True, + limit=100, + limit_per_host=30, + keepalive_timeout=30, + enable_cleanup_closed=True, + ) + timeout = aiohttp.ClientTimeout( + total=120, # 默认超时时间 + connect=30, + sock_read=120, + ) + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Accept': 'text/event-stream', + } + self.session = aiohttp.ClientSession(headers=headers, timeout=timeout, connector=connector) + return self.session + + async def close(self): + """显式关闭会话""" + if self.session and not self.session.closed: + await self.session.close() + self.session = None + + async def upload( + self, + file, + ) -> str: + # 处理 Path 对象 + if isinstance(file, Path): + if not file.exists(): + raise ValueError(f'File not found: {file}') + with open(file, 'rb') as f: + file = f.read() + + # 处理文件路径字符串 + elif isinstance(file, str): + if not os.path.isfile(file): + raise ValueError(f'File not found: {file}') + with open(file, 'rb') as f: + file = f.read() + + # 处理文件对象 + elif hasattr(file, 'read'): + file = file.read() + + session = await self.coze_session() + url = f'{self.api_base}/v1/files/upload' + + try: + file_io = io.BytesIO(file) + async with session.post( + url, + data={ + 'file': file_io, + }, + timeout=aiohttp.ClientTimeout(total=60), + ) as response: + if response.status == 401: + raise Exception('Coze API 认证失败,请检查 API Key 是否正确') + + response_text = await response.text() + + if response.status != 200: + raise Exception(f'文件上传失败,状态码: {response.status}, 响应: {response_text}') + try: + result = await response.json() + except json.JSONDecodeError: + raise Exception(f'文件上传响应解析失败: {response_text}') + + if result.get('code') != 0: + raise Exception(f'文件上传失败: {result.get("msg", "未知错误")}') + + file_id = result['data']['id'] + return file_id + + except asyncio.TimeoutError: + raise Exception('文件上传超时') + except Exception as e: + raise Exception(f'文件上传失败: {str(e)}') + + async def chat_messages( + self, + bot_id: str, + user_id: str, + additional_messages: List[Dict] | None = None, + conversation_id: str | None = None, + auto_save_history: bool = True, + stream: bool = True, + timeout: float = 120, + ) -> AsyncGenerator[Dict[str, Any], None]: + """发送聊天消息并返回流式响应 + + Args: + bot_id: Bot ID + user_id: 用户ID + additional_messages: 额外消息列表 + conversation_id: 会话ID + auto_save_history: 是否自动保存历史 + stream: 是否流式响应 + timeout: 超时时间 + """ + session = await self.coze_session() + url = f'{self.api_base}/v3/chat' + + payload = { + 'bot_id': bot_id, + 'user_id': user_id, + 'stream': stream, + 'auto_save_history': auto_save_history, + } + + if additional_messages: + payload['additional_messages'] = additional_messages + + params = {} + if conversation_id: + params['conversation_id'] = conversation_id + + try: + async with session.post( + url, + json=payload, + params=params, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as response: + if response.status == 401: + raise Exception('Coze API 认证失败,请检查 API Key 是否正确') + + if response.status != 200: + raise Exception(f'Coze API 流式请求失败,状态码: {response.status}') + + async for chunk in response.content: + chunk = chunk.decode('utf-8') + if chunk != '\n': + if chunk.startswith('event:'): + chunk_type = chunk.replace('event:', '', 1).strip() + elif chunk.startswith('data:'): + chunk_data = chunk.replace('data:', '', 1).strip() + else: + yield { + 'event': chunk_type, + 'data': json.loads(chunk_data) if chunk_data else {}, + } # 处理本地部署时,接口返回的data为空值 + + except asyncio.TimeoutError: + raise Exception(f'Coze API 流式请求超时 ({timeout}秒)') + except Exception as e: + raise Exception(f'Coze API 流式请求失败: {str(e)}') diff --git a/src/langbot/libs/deerflow_api/__init__.py b/src/langbot/libs/deerflow_api/__init__.py new file mode 100644 index 0000000..160778b --- /dev/null +++ b/src/langbot/libs/deerflow_api/__init__.py @@ -0,0 +1,5 @@ +from .client import AsyncDeerFlowClient +from .errors import DeerFlowAPIError +from . import stream_utils + +__all__ = ['AsyncDeerFlowClient', 'DeerFlowAPIError', 'stream_utils'] diff --git a/src/langbot/libs/deerflow_api/client.py b/src/langbot/libs/deerflow_api/client.py new file mode 100644 index 0000000..b66bf7e --- /dev/null +++ b/src/langbot/libs/deerflow_api/client.py @@ -0,0 +1,204 @@ +"""DeerFlow LangGraph HTTP API 客户端 + +参考 astrbot 的 deerflow_api_client 实现,使用 httpx 适配 LangBot 风格。 +""" + +from __future__ import annotations + +import codecs +import json +import typing +from collections.abc import AsyncGenerator + +import httpx + +from .errors import DeerFlowAPIError + + +SSE_MAX_BUFFER_CHARS = 1_048_576 + + +def _normalize_sse_newlines(text: str) -> str: + """规范化 CRLF/CR 为 LF,确保 SSE 块分割稳定""" + return text.replace('\r\n', '\n').replace('\r', '\n') + + +def _parse_sse_data_lines(data_lines: list[str]) -> typing.Any: + raw_data = '\n'.join(data_lines) + try: + return json.loads(raw_data) + except json.JSONDecodeError: + # 某些 LangGraph 兼容服务端会在单个 SSE 事件中用多个 data 行 + # 发送多段 JSON 片段(例如 tuple payload) + parsed_lines: list[typing.Any] = [] + can_parse_all = True + for line in data_lines: + line = line.strip() + if not line: + continue + try: + parsed_lines.append(json.loads(line)) + except json.JSONDecodeError: + can_parse_all = False + break + if can_parse_all and parsed_lines: + return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines + return raw_data + + +def _parse_sse_block(block: str) -> dict[str, typing.Any] | None: + if not block.strip(): + return None + + event_name = 'message' + data_lines: list[str] = [] + for line in block.splitlines(): + if line.startswith('event:'): + event_name = line[6:].strip() + elif line.startswith('data:'): + data_lines.append(line[5:].lstrip()) + + if not data_lines: + return None + return {'event': event_name, 'data': _parse_sse_data_lines(data_lines)} + + +class AsyncDeerFlowClient: + """DeerFlow LangGraph HTTP API 客户端""" + + api_base: str + headers: dict[str, str] + + def __init__( + self, + api_base: str = 'http://127.0.0.1:2026', + api_key: str = '', + auth_header: str = '', + ) -> None: + self.api_base = api_base.rstrip('/') + self.headers: dict[str, str] = {} + if auth_header: + self.headers['Authorization'] = auth_header + elif api_key: + self.headers['Authorization'] = f'Bearer {api_key}' + + async def create_thread(self, timeout: float = 20) -> dict[str, typing.Any]: + """创建一个新的 LangGraph thread + + Returns: + 包含 thread_id 等信息的字典 + """ + url = f'{self.api_base}/api/langgraph/threads' + payload = {'metadata': {}} + + async with httpx.AsyncClient( + trust_env=True, + timeout=timeout, + ) as client: + response = await client.post( + url, + headers=self.headers, + json=payload, + ) + if response.status_code not in (200, 201): + raise DeerFlowAPIError( + operation='create thread', + status=response.status_code, + body=response.text, + url=url, + ) + return response.json() + + async def delete_thread(self, thread_id: str, timeout: float = 20) -> None: + """删除指定 thread""" + url = f'{self.api_base}/api/threads/{thread_id}' + + async with httpx.AsyncClient( + trust_env=True, + timeout=timeout, + ) as client: + response = await client.delete(url, headers=self.headers) + if response.status_code not in (200, 202, 204, 404): + raise DeerFlowAPIError( + operation='delete thread', + status=response.status_code, + body=response.text, + url=url, + thread_id=thread_id, + ) + + async def stream_run( + self, + thread_id: str, + payload: dict[str, typing.Any], + timeout: float = 120, + ) -> AsyncGenerator[dict[str, typing.Any], None]: + """运行一次 LangGraph stream 请求,逐事件 yield + + Yields: + 事件字典 {'event': event_name, 'data': parsed_data} + """ + url = f'{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream' + + # 流式请求使用单独的 read timeout 控制 + stream_timeout = httpx.Timeout( + connect=min(timeout, 30), + read=timeout, + write=timeout, + pool=timeout, + ) + + async with httpx.AsyncClient( + trust_env=True, + timeout=stream_timeout, + ) as client: + async with client.stream( + 'POST', + url, + headers={ + **self.headers, + 'Accept': 'text/event-stream', + 'Content-Type': 'application/json', + }, + json=payload, + ) as resp: + if resp.status_code != 200: + body = await resp.aread() + raise DeerFlowAPIError( + operation='runs/stream request', + status=resp.status_code, + body=body.decode('utf-8', errors='replace'), + url=url, + thread_id=thread_id, + ) + + decoder = codecs.getincrementaldecoder('utf-8')('replace') + buffer = '' + + async for chunk in resp.aiter_bytes(8192): + buffer += _normalize_sse_newlines(decoder.decode(chunk)) + + while '\n\n' in buffer: + block, buffer = buffer.split('\n\n', 1) + parsed = _parse_sse_block(block) + if parsed is not None: + yield parsed + + if len(buffer) > SSE_MAX_BUFFER_CHARS: + # 缓冲区过大,强制 flush + parsed = _parse_sse_block(buffer) + if parsed is not None: + yield parsed + buffer = '' + + # flush 剩余内容 + buffer += _normalize_sse_newlines(decoder.decode(b'', final=True)) + while '\n\n' in buffer: + block, buffer = buffer.split('\n\n', 1) + parsed = _parse_sse_block(block) + if parsed is not None: + yield parsed + if buffer.strip(): + parsed = _parse_sse_block(buffer) + if parsed is not None: + yield parsed diff --git a/src/langbot/libs/deerflow_api/errors.py b/src/langbot/libs/deerflow_api/errors.py new file mode 100644 index 0000000..a3a6c0a --- /dev/null +++ b/src/langbot/libs/deerflow_api/errors.py @@ -0,0 +1,30 @@ +from __future__ import annotations + + +class DeerFlowAPIError(Exception): + """DeerFlow API 请求失败""" + + def __init__( + self, + *, + operation: str = '', + status: int = 0, + body: str = '', + url: str = '', + thread_id: str | None = None, + message: str = '', + ) -> None: + self.operation = operation + self.status = status + self.body = body + self.url = url + self.thread_id = thread_id + + if message: + super().__init__(message) + return + + msg = f'DeerFlow {operation} failed: status={status}, url={url}, body={body}' + if thread_id is not None: + msg = f'DeerFlow {operation} failed: thread_id={thread_id}, status={status}, url={url}, body={body}' + super().__init__(msg) diff --git a/src/langbot/libs/deerflow_api/stream_utils.py b/src/langbot/libs/deerflow_api/stream_utils.py new file mode 100644 index 0000000..702cb14 --- /dev/null +++ b/src/langbot/libs/deerflow_api/stream_utils.py @@ -0,0 +1,212 @@ +"""DeerFlow LangGraph 流式响应解析工具 + +参考 astrbot 实现的 deerflow_stream_utils。 +""" + +from __future__ import annotations + +import typing +from collections.abc import Iterable + + +def extract_text(content: typing.Any) -> str: + """从消息 content 中提取纯文本""" + if isinstance(content, str): + return content + if isinstance(content, dict): + if isinstance(content.get('text'), str): + return content['text'] + if 'content' in content: + return extract_text(content.get('content')) + if 'kwargs' in content and isinstance(content['kwargs'], dict): + return extract_text(content['kwargs'].get('content')) + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + item_type = item.get('type') + if item_type == 'text' and isinstance(item.get('text'), str): + parts.append(item['text']) + elif 'content' in item: + parts.append(extract_text(item['content'])) + return '\n'.join([p for p in parts if p]).strip() + return str(content) if content is not None else '' + + +def extract_messages_from_values_data(data: typing.Any) -> list[typing.Any]: + """从 values 事件中提取 messages 列表""" + candidates: list[typing.Any] = [] + if isinstance(data, dict): + candidates.append(data) + if isinstance(data.get('values'), dict): + candidates.append(data['values']) + elif isinstance(data, list): + candidates.extend([x for x in data if isinstance(x, dict)]) + + for item in candidates: + messages = item.get('messages') + if isinstance(messages, list): + return messages + return [] + + +def is_ai_message(message: dict[str, typing.Any]) -> bool: + """判断是否为 AI/assistant 消息""" + role = str(message.get('role', '')).lower() + if role in {'assistant', 'ai'}: + return True + + msg_type = str(message.get('type', '')).lower() + if msg_type in {'ai', 'assistant', 'aimessage', 'aimessagechunk'}: + return True + if 'ai' in msg_type and all(token not in msg_type for token in ('human', 'tool', 'system')): + return True + return False + + +def extract_latest_ai_text(messages: Iterable[typing.Any]) -> str: + """获取最近一条 AI 消息的文本内容""" + if isinstance(messages, (list, tuple)): + iterable = reversed(messages) + else: + iterable = reversed(list(messages)) + + for msg in iterable: + if not isinstance(msg, dict): + continue + if is_ai_message(msg): + text = extract_text(msg.get('content')) + if text: + return text + return '' + + +def extract_latest_ai_message(messages: Iterable[typing.Any]) -> dict[str, typing.Any] | None: + """获取最近一条 AI 消息对象""" + if isinstance(messages, (list, tuple)): + iterable = reversed(messages) + else: + iterable = reversed(list(messages)) + + for msg in iterable: + if not isinstance(msg, dict): + continue + if is_ai_message(msg): + return msg + return None + + +def is_clarification_tool_message(message: dict[str, typing.Any]) -> bool: + """判断是否为澄清问题工具消息""" + msg_type = str(message.get('type', '')).lower() + tool_name = str(message.get('name', '')).lower() + return msg_type == 'tool' and tool_name == 'ask_clarification' + + +def extract_latest_clarification_text(messages: Iterable[typing.Any]) -> str: + """提取最近的澄清问题文本""" + if isinstance(messages, (list, tuple)): + iterable = reversed(messages) + else: + iterable = reversed(list(messages)) + + for msg in iterable: + if not isinstance(msg, dict): + continue + if is_clarification_tool_message(msg): + text = extract_text(msg.get('content')) + if text: + return text + return '' + + +def get_message_id(message: typing.Any) -> str: + """提取消息 ID""" + if not isinstance(message, dict): + return '' + msg_id = message.get('id') + return msg_id if isinstance(msg_id, str) else '' + + +def extract_event_message_obj(data: typing.Any) -> dict[str, typing.Any] | None: + """从事件 data 中提取消息对象""" + msg_obj = data + if isinstance(data, (list, tuple)) and data: + msg_obj = data[0] + if isinstance(msg_obj, dict) and isinstance(msg_obj.get('data'), dict): + msg_obj = msg_obj['data'] + return msg_obj if isinstance(msg_obj, dict) else None + + +def extract_ai_delta_from_event_data(data: typing.Any) -> str: + """从 messages-tuple 事件中提取 AI delta 文本""" + msg_obj = extract_event_message_obj(data) + if not msg_obj: + return '' + if is_ai_message(msg_obj): + return extract_text(msg_obj.get('content')) + return '' + + +def extract_clarification_from_event_data(data: typing.Any) -> str: + """从事件中提取澄清问题""" + msg_obj = extract_event_message_obj(data) + if not msg_obj: + return '' + if is_clarification_tool_message(msg_obj): + return extract_text(msg_obj.get('content')) + return '' + + +def _iter_custom_event_items(data: typing.Any) -> list[dict[str, typing.Any]]: + items: list[dict[str, typing.Any]] = [] + if isinstance(data, dict): + return [data] + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + items.append(item) + elif isinstance(item, (list, tuple)): + for nested in item: + if isinstance(nested, dict): + items.append(nested) + return items + + +def extract_task_failures_from_custom_event(data: typing.Any) -> list[str]: + """从 custom 事件中提取子任务失败信息""" + failures: list[str] = [] + for item in _iter_custom_event_items(data): + event_type = str(item.get('type', '')).lower() + if event_type not in {'task_failed', 'task_timed_out'}: + continue + + task_id = str(item.get('task_id', '')).strip() + error_text = extract_text(item.get('error')).strip() + if task_id and error_text: + failures.append(f'{task_id}: {error_text}') + elif error_text: + failures.append(error_text) + elif task_id: + failures.append(f'{task_id}: unknown error') + else: + failures.append('unknown task failure') + return failures + + +def build_task_failure_summary(failures: list[str]) -> str: + """构建任务失败摘要""" + if not failures: + return '' + deduped: list[str] = [] + seen: set[str] = set() + for failure in failures: + if failure not in seen: + seen.add(failure) + deduped.append(failure) + if len(deduped) == 1: + return f'DeerFlow subtask failed: {deduped[0]}' + joined = '\n'.join([f'- {item}' for item in deduped[:5]]) + return f'DeerFlow subtasks failed:\n{joined}' diff --git a/src/langbot/libs/dify_service_api/README.md b/src/langbot/libs/dify_service_api/README.md new file mode 100644 index 0000000..a94aefe --- /dev/null +++ b/src/langbot/libs/dify_service_api/README.md @@ -0,0 +1,3 @@ +# Dify Service API Python SDK + +这个 SDK 尚不完全支持 Dify Service API 的所有功能。 diff --git a/src/langbot/libs/dify_service_api/__init__.py b/src/langbot/libs/dify_service_api/__init__.py new file mode 100644 index 0000000..bd6f6d4 --- /dev/null +++ b/src/langbot/libs/dify_service_api/__init__.py @@ -0,0 +1,4 @@ +from .v1 import client as client +from .v1 import errors as errors + +__all__ = ['client', 'errors'] diff --git a/src/langbot/libs/dify_service_api/v1/__init__.py b/src/langbot/libs/dify_service_api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/libs/dify_service_api/v1/client.py b/src/langbot/libs/dify_service_api/v1/client.py new file mode 100644 index 0000000..a00eedb --- /dev/null +++ b/src/langbot/libs/dify_service_api/v1/client.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import httpx +import typing +import json + +from .errors import DifyAPIError +from pathlib import Path +import os + + +class AsyncDifyServiceClient: + """Dify Service API 客户端""" + + api_key: str + base_url: str + + def __init__( + self, + api_key: str, + base_url: str = 'https://api.dify.ai/v1', + ) -> None: + self.api_key = api_key + self.base_url = base_url + + async def chat_messages( + self, + inputs: dict[str, typing.Any], + query: str, + user: str, + response_mode: str = 'streaming', # 当前不支持 blocking + conversation_id: str = '', + files: list[dict[str, typing.Any]] = [], + timeout: float = 30.0, + model_config: dict[str, typing.Any] | None = None, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """发送消息""" + if response_mode != 'streaming': + raise DifyAPIError('当前仅支持 streaming 模式') + + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + payload = { + 'inputs': inputs, + 'query': query, + 'user': user, + 'response_mode': response_mode, + 'conversation_id': conversation_id, + 'files': files, + 'model_config': model_config or {}, + } + + async with client.stream( + 'POST', + '/chat-messages', + headers={ + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json', + }, + json=payload, + ) as r: + async for chunk in r.aiter_lines(): + if r.status_code != 200: + raise DifyAPIError(f'{r.status_code} {chunk}') + if chunk.strip() == '': + continue + if chunk.startswith('data:'): + yield json.loads(chunk[5:]) + + async def workflow_run( + self, + inputs: dict[str, typing.Any], + user: str, + response_mode: str = 'streaming', # 当前不支持 blocking + files: list[dict[str, typing.Any]] = [], + timeout: float = 30.0, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """运行工作流""" + if response_mode != 'streaming': + raise DifyAPIError('当前仅支持 streaming 模式') + + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + async with client.stream( + 'POST', + '/workflows/run', + headers={ + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json', + }, + json={ + 'inputs': inputs, + 'user': user, + 'response_mode': response_mode, + 'files': files, + }, + ) as r: + async for chunk in r.aiter_lines(): + if r.status_code != 200: + raise DifyAPIError(f'{r.status_code} {chunk}') + if chunk.strip() == '': + continue + if chunk.startswith('data:'): + yield json.loads(chunk[5:]) + + async def workflow_submit( + self, + form_token: str, + workflow_run_id: str, + inputs: dict[str, typing.Any], + user: str, + action: str = '', + timeout: float = 120.0, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """Submit human input to resume a paused workflow, then stream events. + + 1. POST /form/human_input/{form_token} to submit the form + 2. GET /workflow/{task_id}/events to stream the resumed workflow events + """ + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json', + } + + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + # Step 1: Submit the form + payload: dict[str, typing.Any] = { + 'inputs': inputs if isinstance(inputs, dict) else {}, + 'user': user, + 'action': action, + } + + submit_resp = await client.post( + f'/form/human_input/{form_token}', + headers=headers, + json=payload, + ) + if submit_resp.status_code != 200: + raise DifyAPIError(f'{submit_resp.status_code} {submit_resp.text}') + + # Step 2: Stream resumed workflow events + async with client.stream( + 'GET', + f'/workflow/{workflow_run_id}/events', + headers={'Authorization': f'Bearer {self.api_key}'}, + params={'user': user}, + ) as r: + if r.status_code != 200: + body = (await r.aread()).decode(errors='replace') + raise DifyAPIError(f'{r.status_code} {body}') + async for chunk in r.aiter_lines(): + if chunk.strip() == '': + continue + if chunk.startswith('data:'): + yield json.loads(chunk[5:]) + + async def upload_file( + self, + file: httpx._types.FileTypes, + user: str, + timeout: float = 30.0, + ) -> str: + # 处理 Path 对象 + if isinstance(file, Path): + if not file.exists(): + raise ValueError(f'File not found: {file}') + with open(file, 'rb') as f: + file = f.read() + + # 处理文件路径字符串 + elif isinstance(file, str): + if not os.path.isfile(file): + raise ValueError(f'File not found: {file}') + with open(file, 'rb') as f: + file = f.read() + + # 处理文件对象 + elif hasattr(file, 'read'): + file = file.read() + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + # multipart/form-data + response = await client.post( + '/files/upload', + headers={'Authorization': f'Bearer {self.api_key}'}, + files={ + 'file': file, + }, + data={ + 'user': user, + }, + ) + + if response.status_code != 201: + raise DifyAPIError(f'{response.status_code} {response.text}') + + return response.json() diff --git a/src/langbot/libs/dify_service_api/v1/client_test.py b/src/langbot/libs/dify_service_api/v1/client_test.py new file mode 100644 index 0000000..2695b2e --- /dev/null +++ b/src/langbot/libs/dify_service_api/v1/client_test.py @@ -0,0 +1,17 @@ +from . import client + +import asyncio + +import os + + +class TestDifyClient: + async def test_chat_messages(self): + cln = client.DifyClient(api_key=os.getenv('DIFY_API_KEY')) + + resp = await cln.chat_messages(inputs={}, query='Who are you?', user_id='test') + print(resp) + + +if __name__ == '__main__': + asyncio.run(TestDifyClient().test_chat_messages()) diff --git a/src/langbot/libs/dify_service_api/v1/errors.py b/src/langbot/libs/dify_service_api/v1/errors.py new file mode 100644 index 0000000..0b71fc5 --- /dev/null +++ b/src/langbot/libs/dify_service_api/v1/errors.py @@ -0,0 +1,6 @@ +class DifyAPIError(Exception): + """Dify API 请求失败""" + + def __init__(self, message: str): + self.message = message + super().__init__(self.message) diff --git a/src/langbot/libs/dingtalk_api/EchoHandler.py b/src/langbot/libs/dingtalk_api/EchoHandler.py new file mode 100644 index 0000000..bb05586 --- /dev/null +++ b/src/langbot/libs/dingtalk_api/EchoHandler.py @@ -0,0 +1,29 @@ +import asyncio +import dingtalk_stream # type: ignore +from dingtalk_stream import AckMessage + + +class EchoTextHandler(dingtalk_stream.ChatbotHandler): + def __init__(self, client): + super().__init__() # Call parent class initializer to set up logger + self.msg_id = '' + self.incoming_message = None + self.client = client # 用于更新 DingTalkClient 中的 incoming_message + + """处理钉钉消息""" + + async def process(self, callback: dingtalk_stream.CallbackMessage): + incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data) + if incoming_message.message_id != self.msg_id: + self.msg_id = incoming_message.message_id + + await self.client.update_incoming_message(incoming_message) + + return AckMessage.STATUS_OK, 'OK' + + async def get_incoming_message(self): + """异步等待消息的到来""" + while self.incoming_message is None: + await asyncio.sleep(0.1) # 异步等待,避免阻塞 + + return self.incoming_message diff --git a/src/langbot/libs/dingtalk_api/__init__.py b/src/langbot/libs/dingtalk_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py new file mode 100644 index 0000000..5c9df66 --- /dev/null +++ b/src/langbot/libs/dingtalk_api/api.py @@ -0,0 +1,949 @@ +import asyncio +import base64 +import json +import logging +import os +import time +import typing +import uuid +import urllib.parse +from typing import Awaitable, Callable, Optional +import dingtalk_stream # type: ignore +import websockets +from .EchoHandler import EchoTextHandler +from .card_callback import DingTalkCardActionHandler +from .dingtalkevent import DingTalkEvent +import httpx +import traceback + + +_stdout_logger = logging.getLogger('langbot.dingtalk_api') + + +DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com' + + +def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict: + """DingTalk cardParamMap only accepts string values. + + Keep callers free to pass structured values for template variables such + as button groups or select options, then encode them once at the API + boundary. + """ + if not card_param_map: + return {} + result = {} + for key, value in card_param_map.items(): + if value is None: + result[key] = '' + elif isinstance(value, str): + result[key] = value + else: + result[key] = json.dumps(value, ensure_ascii=False) + return result + + +class DingTalkClient: + def __init__( + self, + client_id: str, + client_secret: str, + robot_name: str, + robot_code: str, + markdown_card: bool, + logger: None, + card_action_callback: Optional[Callable[[dict], Awaitable[None]]] = None, + ): + """初始化 WebSocket 连接并自动启动""" + self.credential = dingtalk_stream.Credential(client_id, client_secret) + self.client = dingtalk_stream.DingTalkStreamClient(self.credential) + self.key = client_id + self.secret = client_secret + # 在 DingTalkClient 中传入自己作为参数,避免循环导入 + self.EchoTextHandler = EchoTextHandler(self) + self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler) + # STREAM-mode card action button click handler. Forwards parsed payload + # to the adapter so it can resume paused Dify workflows. + self.card_action_callback = card_action_callback + self.card_action_handler = DingTalkCardActionHandler(self.client, self._on_card_action) + self.client.register_callback_handler( + dingtalk_stream.handlers.CallbackHandler.TOPIC_CARD_CALLBACK, + self.card_action_handler, + ) + self._message_handlers = { + 'example': [], + } + self.access_token = '' + self.robot_name = robot_name + self.robot_code = robot_code + self.access_token_expiry_time = '' + self.markdown_card = markdown_card + self.logger = logger + # Legacy access_token used by the OLD oapi.dingtalk.com endpoints + # (e.g. /media/upload, which is the only documented way to get an + # `@xxx` media_id usable in card Avatar.imageUrl). The new v1.0 + # token doesn't work there — different auth domain. + self.legacy_access_token = '' + self.legacy_access_token_expiry_time: typing.Optional[float] = None + self._stopped = False # Flag to control the event loop + + async def _on_card_action(self, payload: dict) -> None: + """Dispatch a parsed card-action payload to the adapter callback.""" + if self.card_action_callback is None: + return + try: + await self.card_action_callback(payload) + except Exception: + if self.logger: + await self.logger.error(f'DingTalk card action callback error: {traceback.format_exc()}') + + async def get_access_token(self): + url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken' + headers = {'Content-Type': 'application/json'} + data = {'appKey': self.key, 'appSecret': self.secret} + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, json=data, headers=headers) + if response.status_code == 200: + response_data = response.json() + self.access_token = response_data.get('accessToken') + expires_in = int(response_data.get('expireIn', 7200)) + self.access_token_expiry_time = time.time() + expires_in - 60 + except Exception: + await self.logger.error('failed to get access token in dingtalk') + + async def is_token_expired(self): + """检查token是否过期""" + if self.access_token_expiry_time is None: + return True + return time.time() > self.access_token_expiry_time + + async def check_access_token(self): + if not self.access_token or await self.is_token_expired(): + return False + return bool(self.access_token and self.access_token.strip()) + + async def download_image(self, download_code: str): + if not await self.check_access_token(): + await self.get_access_token() + url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download' + params = {'downloadCode': download_code, 'robotCode': self.robot_code} + headers = {'x-acs-dingtalk-access-token': self.access_token} + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=params) + if response.status_code == 200: + result = response.json() + download_url = result.get('downloadUrl') + else: + await self.logger.error(f'failed to get download url: {response.json()}') + + if download_url: + return await self.download_url_to_base64(download_url) + + async def download_url_to_base64(self, download_url): + async with httpx.AsyncClient() as client: + response = await client.get(download_url) + + if response.status_code == 200: + file_bytes = response.content + mime_type = response.headers.get('Content-Type', 'application/octet-stream') + base64_str = base64.b64encode(file_bytes).decode('utf-8') + return f'data:{mime_type};base64,{base64_str}' + else: + await self.logger.error(f'failed to get files: {response.json()}') + + async def get_audio_url(self, download_code: str): + if not await self.check_access_token(): + await self.get_access_token() + url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download' + params = {'downloadCode': download_code, 'robotCode': self.robot_code} + headers = {'x-acs-dingtalk-access-token': self.access_token} + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=params) + if response.status_code == 200: + result = response.json() + download_url = result.get('downloadUrl') + if download_url: + return await self.download_url_to_base64(download_url) + else: + await self.logger.error(f'failed to get audio: {response.json()}') + else: + raise Exception(f'Error: {response.status_code}, {response.text}') + + async def get_file_url(self, download_code: str): + if not await self.check_access_token(): + await self.get_access_token() + url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download' + params = {'downloadCode': download_code, 'robotCode': self.robot_code} + headers = {'x-acs-dingtalk-access-token': self.access_token} + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=params) + if response.status_code == 200: + result = response.json() + download_url = result.get('downloadUrl') + if download_url: + return download_url + else: + await self.logger.error(f'failed to get file: {response.json()}') + else: + raise Exception(f'Error: {response.status_code}, {response.text}') + + async def update_incoming_message(self, message): + """异步更新 DingTalkClient 中的 incoming_message""" + message_data = await self.get_message(message) + if message_data: + event = DingTalkEvent.from_payload(message_data) + if event: + await self._handle_message(event) + + async def send_message(self, content: str, incoming_message, at: bool): + if self.markdown_card: + if at: + self.EchoTextHandler.reply_markdown( + title='@' + incoming_message.sender_nick + ' ' + content, + text='@' + incoming_message.sender_nick + ' ' + content, + incoming_message=incoming_message, + ) + else: + self.EchoTextHandler.reply_markdown( + title=content, + text=content, + incoming_message=incoming_message, + ) + else: + self.EchoTextHandler.reply_text(content, incoming_message) + + async def get_incoming_message(self): + """获取收到的消息""" + return await self.EchoTextHandler.get_incoming_message() + + def on_message(self, msg_type: str): + def decorator(func: Callable[[DingTalkEvent], None]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + + return decorator + + async def _handle_message(self, event: DingTalkEvent): + """ + 处理消息事件。 + """ + # Skip message handling if stopped + if self._stopped: + return + msg_type = event.conversation + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + + async def _parse_quoted_message(self, replied_msg: dict) -> dict: + """Parse the quoted/replied message and extract its content. + + Args: + replied_msg: The repliedMsg object from DingTalk message + + Returns: + A dict containing the quoted message info with keys: + - message_id: The original message ID + - msg_type: The message type (text, file, picture, audio, etc.) + - content: The text content (if any) + - file_url: The file download URL (if file type) + - file_name: The file name (if file type) + - picture: The picture base64 (if picture type) + - audio: The audio base64 (if audio type) + """ + quote_info = { + 'message_id': replied_msg.get('msgId', ''), + 'msg_type': replied_msg.get('msgType', ''), + 'sender_id': replied_msg.get('senderId', ''), + } + + msg_type = replied_msg.get('msgType', '') + content = replied_msg.get('content', {}) + + # Handle content as string (JSON) or dict + if isinstance(content, str): + try: + content = json.loads(content) + except (json.JSONDecodeError, TypeError): + content = {} + + if msg_type == 'text': + # Text message + if isinstance(content, dict): + quote_info['content'] = content.get('content', '') + else: + quote_info['content'] = str(content) + + elif msg_type == 'file': + # File message + download_code = content.get('downloadCode') + file_name = content.get('fileName') + if download_code and file_name: + try: + quote_info['file_url'] = await self.get_file_url(download_code) + quote_info['file_name'] = file_name + except Exception as e: + if self.logger: + await self.logger.error(f'Failed to get quoted file URL: {e}') + + elif msg_type == 'picture': + # Picture message + download_code = content.get('downloadCode') + if download_code: + try: + quote_info['picture'] = await self.download_image(download_code) + except Exception as e: + if self.logger: + await self.logger.error(f'Failed to download quoted image: {e}') + + elif msg_type == 'audio': + # Audio message + download_code = content.get('downloadCode') + if download_code: + try: + quote_info['audio'] = await self.get_audio_url(download_code) + except Exception as e: + if self.logger: + await self.logger.error(f'Failed to get quoted audio: {e}') + + elif msg_type == 'richText': + # Rich text message - extract text content + rich_text = content.get('richText', []) + texts = [] + for item in rich_text: + if 'text' in item and item['text'] != '\n': + texts.append(item['text']) + quote_info['content'] = '\n'.join(texts) + + return quote_info + + async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage): + try: + # print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False)) + message_data = { + 'IncomingMessage': incoming_message, + } + if str(incoming_message.conversation_type) == '1': + message_data['conversation_type'] = 'FriendMessage' + elif str(incoming_message.conversation_type) == '2': + message_data['conversation_type'] = 'GroupMessage' + + # Check for quoted/replied message + raw_data = incoming_message.to_dict() + text_data = raw_data.get('text', {}) + if isinstance(text_data, dict) and text_data.get('isReplyMsg'): + replied_msg = text_data.get('repliedMsg', {}) + if replied_msg: + quote_info = await self._parse_quoted_message(replied_msg) + message_data['QuotedMessage'] = quote_info + + if incoming_message.message_type == 'richText': + data = incoming_message.rich_text_content.to_dict() + + # 使用统一的结构化数据格式,保持顺序 + rich_content = { + 'Type': 'richText', + 'Elements': [], # 按顺序存储所有元素 + 'SimpleContent': '', # 兼容字段:纯文本内容 + 'SimplePicture': '', # 兼容字段:第一张图片 + } + + # 先收集所有文本和图片占位符 + text_elements = [] + + # 解析富文本内容,保持原始顺序 + for item in data['richText']: + # 处理文本内容 + if 'text' in item and item['text'] != '\n': + element = {'Type': 'text', 'Content': item['text']} + rich_content['Elements'].append(element) + text_elements.append(item['text']) + + # 检查是否是图片元素 - 根据钉钉API的实际结构调整 + # 钉钉富文本中的图片通常有特定标识,可能需要根据实际返回调整 + elif item.get('type') == 'picture': + # 创建图片占位符 + element = { + 'Type': 'image_placeholder', + } + rich_content['Elements'].append(element) + + # 获取并下载所有图片 + image_list = incoming_message.get_image_list() + if image_list: + new_elements = [] + image_index = 0 + + for element in rich_content['Elements']: + if element['Type'] == 'image_placeholder': + if image_index < len(image_list) and image_list[image_index]: + image_url = await self.download_image(image_list[image_index]) + new_elements.append({'Type': 'image', 'Picture': image_url}) + image_index += 1 + else: + # 如果没有对应的图片,保留占位符或跳过 + continue + else: + new_elements.append(element) + + rich_content['Elements'] = new_elements + + # 设置兼容字段 + all_texts = [elem['Content'] for elem in rich_content['Elements'] if elem.get('Type') == 'text'] + rich_content['SimpleContent'] = '\n'.join(all_texts) if all_texts else '' + + all_images = [elem['Picture'] for elem in rich_content['Elements'] if elem.get('Type') == 'image'] + if all_images: + rich_content['SimplePicture'] = all_images[0] + rich_content['AllImages'] = all_images # 所有图片的列表 + + # 设置原始的 content 和 picture 字段以保持兼容 + message_data['Content'] = rich_content['SimpleContent'] + message_data['Rich_Content'] = rich_content + if all_images: + message_data['Picture'] = all_images[0] + + elif incoming_message.message_type == 'text': + message_data['Content'] = incoming_message.get_text_list()[0] + + message_data['Type'] = 'text' + elif incoming_message.message_type == 'picture': + message_data['Picture'] = await self.download_image(incoming_message.get_image_list()[0]) + + message_data['Type'] = 'image' + elif incoming_message.message_type == 'audio': + raw_content = incoming_message.to_dict().get('content', {}) + # 兼容处理:如果 content 仍为 JSON 字符串则进行解析 + if isinstance(raw_content, str): + try: + raw_content = json.loads(raw_content) + except (json.JSONDecodeError, TypeError): + raw_content = {} + + if self.logger: + await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}') + + # 提取钉钉自带的语音转写文字(Powered by Qwen) + recognition = raw_content.get('recognition', '') + if recognition: + message_data['Content'] = recognition + + download_code = raw_content.get('downloadCode') + if download_code: + message_data['Audio'] = await self.get_audio_url(download_code) + + message_data['Type'] = 'audio' + elif incoming_message.message_type == 'file': + # 获取原始数据字典并提取嵌套的文件信息 + raw_data = incoming_message.to_dict() + file_info = raw_data.get('content', {}) + + # 兼容处理:如果 content 仍为 JSON 字符串则进行解析 + if isinstance(file_info, str): + try: + file_info = json.loads(file_info) + except (json.JSONDecodeError, TypeError): + file_info = {} + + download_code = file_info.get('downloadCode') + file_name = file_info.get('fileName') + + if download_code and file_name: + # 转换 downloadCode 为可下载的真实 URL + message_data['File'] = await self.get_file_url(download_code) + message_data['Name'] = file_name + else: + if self.logger: + await self.logger.error(f'Failed to extract file info from message content: {file_info}') + message_data['File'] = None + message_data['Name'] = None + + message_data['Type'] = 'file' + + copy_message_data = message_data.copy() + del copy_message_data['IncomingMessage'] + # print("message_data:", json.dumps(copy_message_data, indent=4, ensure_ascii=False)) + except Exception: + if self.logger: + await self.logger.error(f'Error in get_message: {traceback.format_exc()}') + else: + traceback.print_exc() + + return message_data + + async def send_proactive_message_to_one(self, target_id: str, content: str): + if not await self.check_access_token(): + await self.get_access_token() + + url = 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend' + + headers = { + 'x-acs-dingtalk-access-token': self.access_token, + 'Content-Type': 'application/json', + } + + # For enterprise-internal robots, robotCode == AppKey (client_id). + # The dedicated robot_code field is only required for scenario-group + # robots or third-party robots; fall back to client_id when empty so + # the common single-bot setup keeps working without manual config. + robot_code = self.robot_code or self.key + data = { + 'robotCode': robot_code, + 'userIds': [target_id], + 'msgKey': 'sampleText', + 'msgParam': json.dumps({'content': content}), + } + _stdout_logger.info( + 'DingTalk send_proactive_message_to_one request: robotCode=%s target_id=%s content_len=%d', + robot_code, + target_id, + len(content), + ) + try: + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=data) + _stdout_logger.info( + 'DingTalk send_proactive_message_to_one response: status=%d body=%s', + response.status_code, + response.text[:500], + ) + if response.status_code == 200: + return + except Exception: + _stdout_logger.exception('DingTalk send_proactive_message_to_one error') + await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}') + raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}') + + async def send_proactive_message_to_group(self, target_id: str, content: str): + if not await self.check_access_token(): + await self.get_access_token() + + url = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send' + + headers = { + 'x-acs-dingtalk-access-token': self.access_token, + 'Content-Type': 'application/json', + } + + data = { + 'robotCode': self.robot_code or self.key, + 'openConversationId': target_id, + 'msgKey': 'sampleText', + 'msgParam': json.dumps({'content': content}), + } + try: + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=data) + if response.status_code == 200: + return + except Exception: + await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}') + raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}') + + async def create_and_card( + self, + temp_card_id: str, + incoming_message: dingtalk_stream.ChatbotMessage, + quote_origin: bool = False, + card_auto_layout: bool = False, + ): + """Create + deliver the streaming chat card for a chatbot reply. + + Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns + `(None, out_track_id)` to keep call sites compatible with the + previous `(card_instance, card_instance_id)` shape — the first slot + is unused now that everything is driven by out_track_id. + """ + out_track_id = uuid.uuid4().hex + is_group = str(incoming_message.conversation_type) == '2' + if is_group: + open_space_id = f'dtv1.card//IM_GROUP.{incoming_message.conversation_id}' + else: + open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}' + + card_param_map = {'content': ''} + if incoming_message.message_type == 'text': + card_param_map['query'] = incoming_message.get_text_list()[0] + else: + card_param_map['query'] = '...' + + await self.create_and_deliver_card( + card_template_id=temp_card_id, + out_track_id=out_track_id, + open_space_id=open_space_id, + is_group=is_group, + card_param_map=card_param_map, + card_data_config={'autoLayout': card_auto_layout}, + ) + return None, out_track_id + + async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool): + """Stream a single chunk into an existing card's `content` field.""" + try: + await self.streaming_update_card( + out_track_id=card_instance_id, + content_key='content', + content_value=content, + append=False, + finished=is_final, + failed=False, + ) + except Exception as e: + if self.logger: + self.logger.exception(e) + await self.streaming_update_card( + out_track_id=card_instance_id, + content_key='content', + content_value='', + append=False, + finished=is_final, + failed=True, + ) + + async def create_and_deliver_card( + self, + *, + card_template_id: str, + out_track_id: str, + open_space_id: str, + is_group: bool, + card_param_map: Optional[dict] = None, + callback_type: str = 'STREAM', + callback_route_key: Optional[str] = None, + support_forward: bool = True, + dynamic_data_source_configs: Optional[list] = None, + card_data_config: Optional[dict] = None, + at_user_ids: Optional[dict] = None, + recipients: Optional[list] = None, + ) -> bool: + """POST /v1.0/card/instances/createAndDeliver. + + Mirrors the SDK's `async_create_and_deliver_card` shape but exposes + the dynamic-data-source config slot so we can register a pull URL + for variable-length button lists. + """ + if not await self.check_access_token(): + await self.get_access_token() + + cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)} + if card_data_config is not None: + cardData['config'] = json.dumps(card_data_config) + + body: dict = { + 'cardTemplateId': card_template_id, + 'outTrackId': out_track_id, + 'cardData': cardData, + 'callbackType': callback_type, + 'openSpaceId': open_space_id, + 'imGroupOpenSpaceModel': {'supportForward': support_forward}, + 'imRobotOpenSpaceModel': {'supportForward': support_forward}, + } + if callback_type == 'HTTP' and callback_route_key: + body['callbackRouteKey'] = callback_route_key + + if is_group: + deliver: dict = {'robotCode': self.robot_code or self.key} + if at_user_ids: + deliver['atUserIds'] = at_user_ids + if recipients is not None: + deliver['recipients'] = recipients + body['imGroupOpenDeliverModel'] = deliver + else: + body['imRobotOpenDeliverModel'] = {'spaceType': 'IM_ROBOT'} + + if dynamic_data_source_configs: + body['openDynamicDataConfig'] = {'dynamicDataSourceConfigs': dynamic_data_source_configs} + + url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances/createAndDeliver' + headers = { + 'x-acs-dingtalk-access-token': self.access_token, + 'Content-Type': 'application/json', + } + try: + _stdout_logger.info( + 'DingTalk createAndDeliver request body: %s', + json.dumps(body, ensure_ascii=False)[:1500], + ) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=body, timeout=30.0) + if response.status_code == 200: + _stdout_logger.info( + 'DingTalk createAndDeliver response: %s', + response.text[:500], + ) + return True + _stdout_logger.error( + 'DingTalk createAndDeliver failed: status=%s body=%s', + response.status_code, + response.text, + ) + if self.logger: + await self.logger.error( + f'DingTalk createAndDeliver failed: status={response.status_code} body={response.text}' + ) + return False + except Exception: + _stdout_logger.exception('DingTalk createAndDeliver error') + if self.logger: + await self.logger.error(f'DingTalk createAndDeliver error: {traceback.format_exc()}') + return False + + async def streaming_update_card( + self, + *, + out_track_id: str, + content_key: str, + content_value: str, + append: bool, + finished: bool, + failed: bool = False, + ) -> bool: + """PUT /v1.0/card/streaming. + + Replaces `dingtalk_stream.AICardReplier.async_streaming` — same body + shape (outTrackId / guid / key / content / isFull / isFinalize / + isError) per the SDK source. + """ + if not await self.check_access_token(): + await self.get_access_token() + + body = { + 'outTrackId': out_track_id, + 'guid': uuid.uuid4().hex, + 'key': content_key, + 'content': content_value, + 'isFull': not append, + 'isFinalize': finished, + 'isError': failed, + } + url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/streaming' + headers = { + 'x-acs-dingtalk-access-token': self.access_token, + 'Content-Type': 'application/json', + } + try: + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers, json=body, timeout=30.0) + if response.status_code == 200: + return True + if self.logger: + await self.logger.error( + f'DingTalk card streaming failed: status={response.status_code} body={response.text}' + ) + return False + except Exception: + if self.logger: + await self.logger.error(f'DingTalk card streaming error: {traceback.format_exc()}') + return False + + async def update_card_data( + self, + *, + out_track_id: str, + card_param_map: Optional[dict] = None, + private_data: Optional[dict] = None, + ) -> bool: + """PUT /v1.0/card/instances — non-streaming card content update.""" + if not await self.check_access_token(): + await self.get_access_token() + + body: dict = { + 'outTrackId': out_track_id, + 'cardData': {'cardParamMap': _stringify_card_param_map(card_param_map)}, + } + if private_data: + body['privateData'] = private_data + + url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances' + headers = { + 'x-acs-dingtalk-access-token': self.access_token, + 'Content-Type': 'application/json', + } + try: + _stdout_logger.info( + 'DingTalk update_card_data request: out_track_id=%s body=%s', + out_track_id, + json.dumps(body, ensure_ascii=False)[:1500], + ) + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers, json=body, timeout=30.0) + _stdout_logger.info( + 'DingTalk update_card_data response: status=%d body=%s', + response.status_code, + response.text[:300], + ) + if response.status_code == 200: + return True + if self.logger: + await self.logger.error( + f'DingTalk update card failed: status={response.status_code} body={response.text}' + ) + return False + except Exception: + _stdout_logger.exception('DingTalk update_card_data error') + if self.logger: + await self.logger.error(f'DingTalk update card error: {traceback.format_exc()}') + return False + + async def get_legacy_access_token(self) -> Optional[str]: + """Fetch the LEGACY (oapi.dingtalk.com) access_token. This is a + different auth domain from the v1.0 token cached in + ``self.access_token`` — only the legacy token authorises the + ``/media/upload`` endpoint that returns an ``@xxx`` media_id + consumable by card components like Avatar.imageUrl. + + Returns the token string on success, None on failure. Caches + with a 60s safety margin before the documented 7200s expiry. + """ + now = time.time() + if ( + self.legacy_access_token + and self.legacy_access_token_expiry_time + and now < self.legacy_access_token_expiry_time + ): + return self.legacy_access_token + + url = 'https://oapi.dingtalk.com/gettoken' + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, params={'appkey': self.key, 'appsecret': self.secret}, timeout=15.0) + data = response.json() if response.status_code == 200 else {} + if data.get('errcode') == 0 and data.get('access_token'): + self.legacy_access_token = data['access_token'] + expires_in = int(data.get('expires_in', 7200)) + self.legacy_access_token_expiry_time = now + expires_in - 60 + return self.legacy_access_token + if self.logger: + await self.logger.error( + f'DingTalk legacy gettoken failed: status={response.status_code} body={response.text[:200]}' + ) + except Exception: + _stdout_logger.exception('DingTalk legacy gettoken error') + if self.logger: + await self.logger.error(f'DingTalk legacy gettoken error: {traceback.format_exc()}') + return None + + async def upload_image_media(self, file_path: str) -> Optional[str]: + """Upload an image file to DingTalk media storage and return the + ``@xxx`` media_id, which can be passed straight into card variables + like Avatar.imageUrl. Endpoint: + + POST https://oapi.dingtalk.com/media/upload?access_token=…&type=image + + Returns the media_id on success, None on any failure (caller + should handle a None gracefully — DingTalk falls back to a + default avatar when imageUrl is empty/unknown). + """ + if not os.path.exists(file_path): + if self.logger: + await self.logger.error(f'DingTalk upload_image_media: file not found {file_path}') + return None + + token = await self.get_legacy_access_token() + if not token: + return None + + url = 'https://oapi.dingtalk.com/media/upload' + try: + with open(file_path, 'rb') as f: + file_bytes = f.read() + file_name = os.path.basename(file_path) + # Best-effort content-type guess; DingTalk accepts the major image + # mime types and otherwise infers from the bytes. + ext = os.path.splitext(file_name)[1].lower().lstrip('.') + mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get( + ext, 'application/octet-stream' + ) + async with httpx.AsyncClient() as client: + response = await client.post( + url, + params={'access_token': token, 'type': 'image'}, + files={'media': (file_name, file_bytes, mime)}, + timeout=30.0, + ) + data = response.json() if response.status_code == 200 else {} + if data.get('errcode') == 0 and data.get('media_id'): + _stdout_logger.info('DingTalk upload_image_media OK: media_id=%s', data['media_id']) + return data['media_id'] + if self.logger: + await self.logger.error( + f'DingTalk upload_image_media failed: status={response.status_code} body={response.text[:300]}' + ) + except Exception: + _stdout_logger.exception('DingTalk upload_image_media error') + if self.logger: + await self.logger.error(f'DingTalk upload_image_media error: {traceback.format_exc()}') + return None + + async def start(self): + """启动 WebSocket 连接,监听消息""" + self._stopped = False + self.client.pre_start() + + while not self._stopped: + try: + connection = self.client.open_connection() + + if not connection: + if self.logger: + await self.logger.error('DingTalk: open connection failed') + await asyncio.sleep(10) + continue + + uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket'])) + async with websockets.connect(uri) as websocket: + self.client.websocket = websocket + keepalive_task = asyncio.create_task(self._keepalive(websocket)) + try: + async for raw_message in websocket: + if self._stopped: + break + json_message = json.loads(raw_message) + asyncio.create_task(self.client.background_task(json_message)) + finally: + keepalive_task.cancel() + try: + await keepalive_task + except asyncio.CancelledError: + pass + except asyncio.CancelledError: + # Properly exit when task is cancelled + break + except websockets.exceptions.ConnectionClosedError as e: + if self._stopped: + break + if self.logger: + await self.logger.error(f'DingTalk: connection closed, reconnecting... error={e}') + await asyncio.sleep(5) + continue + except Exception as e: + if self._stopped: + break + if self.logger: + await self.logger.error(f'DingTalk: unknown exception, reconnecting... error={e}') + await asyncio.sleep(3) + continue + + async def _keepalive(self, ws, ping_interval=60): + """Keep WebSocket connection alive""" + while not self._stopped: + await asyncio.sleep(ping_interval) + try: + await ws.ping() + except websockets.exceptions.ConnectionClosed: + break + + async def stop(self): + """停止 WebSocket 连接""" + self._stopped = True + # Close WebSocket connection if exists + if self.client.websocket: + try: + await self.client.websocket.close() + except Exception: + pass + # Clear message handlers to prevent stale callbacks + self._message_handlers = {'example': []} diff --git a/src/langbot/libs/dingtalk_api/card_callback.py b/src/langbot/libs/dingtalk_api/card_callback.py new file mode 100644 index 0000000..4634a69 --- /dev/null +++ b/src/langbot/libs/dingtalk_api/card_callback.py @@ -0,0 +1,106 @@ +"""STREAM-mode handler for DingTalk card action button clicks. + +DingTalk delivers card-action callbacks over the same WebSocket stream used +for chatbot messages, under the topic `/v1.0/card/instances/callback`. This +module subclasses `dingtalk_stream.CallbackHandler` and forwards the parsed +payload to a coroutine the adapter registers, so the resume-paused-workflow +logic stays in the platform adapter where it belongs. + +The `CardCallbackMessage` returned by `from_dict` exposes: + +* `card_instance_id` (from `outTrackId`) — the card whose button was clicked +* `user_id` — the clicker's userId +* `content` — parsed JSON; the click params live here. Where exactly inside + `content` they sit depends on the template binding. We probe + the common paths. +* `extension` — parsed JSON; any extra data we set when delivering the card. +""" + +from __future__ import annotations + +from typing import Awaitable, Callable, Optional + +import dingtalk_stream # type: ignore +from dingtalk_stream import AckMessage +from dingtalk_stream.card_callback import CardCallbackMessage + + +_PARAM_PATHS = ( + ('params',), + ('cardPrivateData', 'params'), + ('userPrivateData', 'params'), + ('actionData', 'cardPrivateData', 'params'), +) + + +def _extract_params(content: dict) -> dict: + """Return the action params dict regardless of where the template put it.""" + for path in _PARAM_PATHS: + node = content + for key in path: + if not isinstance(node, dict): + node = None + break + node = node.get(key) + if node is None: + break + if isinstance(node, dict) and node: + return node + return {} + + +def _merge_params(*sources: dict) -> dict: + merged = {} + for source in sources: + if isinstance(source, dict): + merged.update(source) + return merged + + +class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler): + def __init__( + self, + dingtalk_stream_client, + on_action: Optional[Callable[[dict], Awaitable[None]]] = None, + ): + super().__init__() + self.dingtalk_client = dingtalk_stream_client + self.on_action = on_action + + async def process(self, callback: dingtalk_stream.CallbackMessage): + try: + message = CardCallbackMessage.from_dict(callback.data) + content = message.content if isinstance(message.content, dict) else {} + + # `CardCallbackMessage.from_dict` does not surface `actionId` (the + # top-level field that ButtonGroup's sendCardRequest event puts + # there). Pull it from the raw callback.data instead. + raw = callback.data if isinstance(callback.data, dict) else {} + params = _merge_params(_extract_params(content), _extract_params(raw)) + action_id = raw.get('actionId') or '' + if not action_id: + # Some templates nest it under actionData / cardPrivateData. + action_data = raw.get('actionData') or {} + if isinstance(action_data, dict): + action_id = action_data.get('actionId') or action_id + if not action_id: + cpd = action_data.get('cardPrivateData') or {} + if isinstance(cpd, dict): + ids = cpd.get('actionIds') + if isinstance(ids, list) and ids: + action_id = str(ids[0]) + + payload = { + 'out_track_id': message.card_instance_id, + 'user_id': message.user_id, + 'corp_id': message.corp_id, + 'action_id': action_id, + 'params': params, + 'raw_content': message.content, + 'extension': message.extension if isinstance(message.extension, dict) else {}, + } + if self.on_action is not None: + await self.on_action(payload) + except Exception as e: + self.logger.error(f'DingTalkCardActionHandler.process error: {e}') + return AckMessage.STATUS_OK, 'OK' diff --git a/src/langbot/libs/dingtalk_api/dingtalkevent.py b/src/langbot/libs/dingtalk_api/dingtalkevent.py new file mode 100644 index 0000000..be6842e --- /dev/null +++ b/src/langbot/libs/dingtalk_api/dingtalkevent.py @@ -0,0 +1,95 @@ +from typing import Dict, Any, Optional +import dingtalk_stream # type: ignore + + +class DingTalkEvent(dict): + @staticmethod + def from_payload(payload: Dict[str, Any]) -> Optional['DingTalkEvent']: + try: + event = DingTalkEvent(payload) + return event + except KeyError: + return None + + @property + def content(self): + return self.get('Content', '') + + @property + def rich_content(self): + return self.get('Rich_Content', '') + + @property + def incoming_message(self) -> Optional['dingtalk_stream.chatbot.ChatbotMessage']: + return self.get('IncomingMessage') + + @property + def type(self): + return self.get('Type', '') + + @property + def picture(self): + return self.get('Picture', '') + + @property + def audio(self): + return self.get('Audio', '') + + @property + def file(self): + return self.get('File', '') + + @property + def name(self): + return self.get('Name', '') + + @property + def conversation(self): + return self.get('conversation_type', '') + + @property + def quoted_message(self) -> Optional[Dict[str, Any]]: + """Get the quoted/replied message info if this is a reply message. + + Returns: + A dict containing: + - message_id: The original message ID + - msg_type: The message type (text, file, picture, audio, etc.) + - content: The text content (if any) + - file_url: The file download URL (if file type) + - file_name: The file name (if file type) + - picture: The picture base64 (if picture type) + - audio: The audio base64 (if audio type) + """ + return self.get('QuotedMessage') + + def __getattr__(self, key: str) -> Optional[Any]: + """ + 允许通过属性访问数据中的任意字段。 + + Args: + key (str): 字段名。 + + Returns: + Optional[Any]: 字段值。 + """ + return self.get(key) + + def __setattr__(self, key: str, value: Any) -> None: + """ + 允许通过属性设置数据中的任意字段。 + + Args: + key (str): 字段名。 + value (Any): 字段值。 + """ + self[key] = value + + def __repr__(self) -> str: + """ + 生成事件对象的字符串表示。 + + Returns: + str: 字符串表示。 + """ + return f'' diff --git a/src/langbot/libs/official_account_api/__init__.py b/src/langbot/libs/official_account_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/libs/official_account_api/api.py b/src/langbot/libs/official_account_api/api.py new file mode 100644 index 0000000..b474205 --- /dev/null +++ b/src/langbot/libs/official_account_api/api.py @@ -0,0 +1,398 @@ +# 微信公众号的加解密算法与企业微信一样,所以直接使用企业微信的加解密算法文件 +import time +import traceback +from langbot.libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt +import xml.etree.ElementTree as ET +from quart import Quart, request +import hashlib +from typing import Callable +from langbot.libs.official_account_api.oaevent import OAEvent + +import asyncio + + +xml_template = """ + + + + {create_time} + + + +""" + + +class OAClient: + def __init__( + self, + token: str, + EncodingAESKey: str, + AppID: str, + Appsecret: str, + logger: None, + unified_mode: bool = False, + api_base_url: str = 'https://api.weixin.qq.com', + ): + self.token = token + self.aes = EncodingAESKey + self.appid = AppID + self.appsecret = Appsecret + self.base_url = api_base_url + self.access_token = '' + self.unified_mode = unified_mode + self.app = Quart(__name__) + + # 只有在非统一模式下才注册独立路由 + if not self.unified_mode: + self.app.add_url_rule( + '/callback/command', + 'handle_callback', + self.handle_callback_request, + methods=['GET', 'POST'], + ) + + self._message_handlers = { + 'example': [], + } + self.access_token_expiry_time = None + self.msg_id_map = {} + self.generated_content = {} + self.logger = logger + + async def handle_callback_request(self): + """处理回调请求(独立端口模式,使用全局 request)。""" + return await self._handle_callback_internal(request) + + async def handle_unified_webhook(self, req): + """处理回调请求(统一 webhook 模式,显式传递 request)。 + + Args: + req: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 + + Args: + req: Quart Request 对象 + """ + try: + # 每隔100毫秒查询是否生成ai回答 + start_time = time.time() + signature = req.args.get('signature', '') + timestamp = req.args.get('timestamp', '') + nonce = req.args.get('nonce', '') + echostr = req.args.get('echostr', '') + msg_signature = req.args.get('msg_signature', '') + if msg_signature is None: + await self.logger.error('msg_signature不在请求体中') + raise Exception('msg_signature不在请求体中') + + if req.method == 'GET': + # 校验签名 + check_str = ''.join(sorted([self.token, timestamp, nonce])) + check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest() + + if check_signature == signature: + return echostr # 验证成功返回echostr + else: + await self.logger.error('拒绝请求') + raise Exception('拒绝请求') + elif req.method == 'POST': + encryt_msg = await req.data + wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) + ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce) + xml_msg = xml_msg.decode('utf-8') + + if ret != 0: + await self.logger.error('消息解密失败') + raise Exception('消息解密失败') + + message_data = await self.get_message(xml_msg) + if message_data: + event = OAEvent.from_payload(message_data) + if event: + await self._handle_message(event) + + root = ET.fromstring(xml_msg) + from_user = root.find('FromUserName').text # 发送者 + to_user = root.find('ToUserName').text # 机器人 + + timeout = 4.80 + interval = 0.1 + while True: + content = self.generated_content.pop(message_data['MsgId'], None) + if content: + response_xml = xml_template.format( + to_user=from_user, + from_user=to_user, + create_time=int(time.time()), + content=content, + ) + + return response_xml + + if time.time() - start_time >= timeout: + break + + await asyncio.sleep(interval) + + if self.msg_id_map.get(message_data['MsgId'], 1) == 3: + # response_xml = xml_template.format( + # to_user=from_user, + # from_user=to_user, + # create_time=int(time.time()), + # content = "请求失效:暂不支持公众号超过15秒的请求,如有需求,请联系 LangBot 团队。" + # ) + print('请求失效:暂不支持公众号超过15秒的请求,如有需求,请联系 LangBot 团队。') + return '' + + except Exception: + await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}') + traceback.print_exc() + + async def get_message(self, xml_msg: str): + root = ET.fromstring(xml_msg) + + message_data = { + 'ToUserName': root.find('ToUserName').text, + 'FromUserName': root.find('FromUserName').text, + 'CreateTime': int(root.find('CreateTime').text), + 'MsgType': root.find('MsgType').text, + 'Content': root.find('Content').text if root.find('Content') is not None else None, + 'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None, + } + + return message_data + + async def run_task(self, host: str, port: int, *args, **kwargs): + """ + 启动 Quart 应用。 + """ + await self.app.run_task(host=host, port=port, *args, **kwargs) + + def on_message(self, msg_type: str): + """ + 注册消息类型处理器。 + """ + + def decorator(func: Callable[[OAEvent], None]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + + return decorator + + async def _handle_message(self, event: OAEvent): + """ + 处理消息事件。 + """ + message_id = event.message_id + if message_id in self.msg_id_map.keys(): + self.msg_id_map[message_id] += 1 + return + + self.msg_id_map[message_id] = 1 + msg_type = event.type + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + + async def set_message(self, msg_id: int, content: str): + self.generated_content[msg_id] = content + + +class OAClientForLongerResponse: + def __init__( + self, + token: str, + EncodingAESKey: str, + AppID: str, + Appsecret: str, + LoadingMessage: str, + logger: None, + unified_mode: bool = False, + api_base_url: str = 'https://api.weixin.qq.com', + ): + self.token = token + self.aes = EncodingAESKey + self.appid = AppID + self.appsecret = Appsecret + self.base_url = api_base_url + self.access_token = '' + self.unified_mode = unified_mode + self.app = Quart(__name__) + + # 只有在非统一模式下才注册独立路由 + if not self.unified_mode: + self.app.add_url_rule( + '/callback/command', + 'handle_callback', + self.handle_callback_request, + methods=['GET', 'POST'], + ) + + self._message_handlers = { + 'example': [], + } + self.access_token_expiry_time = None + self.loading_message = LoadingMessage + self.msg_queue = {} + self.user_msg_queue = {} + self.logger = logger + + async def handle_callback_request(self): + """处理回调请求(独立端口模式,使用全局 request)。""" + return await self._handle_callback_internal(request) + + async def handle_unified_webhook(self, req): + """处理回调请求(统一 webhook 模式,显式传递 request)。 + + Args: + req: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 + + Args: + req: Quart Request 对象 + """ + try: + signature = req.args.get('signature', '') + timestamp = req.args.get('timestamp', '') + nonce = req.args.get('nonce', '') + echostr = req.args.get('echostr', '') + msg_signature = req.args.get('msg_signature', '') + + if msg_signature is None: + await self.logger.error('msg_signature不在请求体中') + raise Exception('msg_signature不在请求体中') + + if req.method == 'GET': + check_str = ''.join(sorted([self.token, timestamp, nonce])) + check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest() + return echostr if check_signature == signature else '拒绝请求' + + elif req.method == 'POST': + encryt_msg = await req.data + wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) + ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce) + xml_msg = xml_msg.decode('utf-8') + + if ret != 0: + await self.logger.error('消息解密失败') + raise Exception('消息解密失败') + + # 解析 XML + root = ET.fromstring(xml_msg) + from_user = root.find('FromUserName').text + to_user = root.find('ToUserName').text + + if self.msg_queue.get(from_user) and self.msg_queue[from_user][0]['content']: + queue_top = self.msg_queue[from_user].pop(0) + queue_content = queue_top['content'] + + # 弹出用户消息 + if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user]: + self.user_msg_queue[from_user].pop(0) + + response_xml = xml_template.format( + to_user=from_user, + from_user=to_user, + create_time=int(time.time()), + content=queue_content, + ) + return response_xml + + else: + response_xml = xml_template.format( + to_user=from_user, + from_user=to_user, + create_time=int(time.time()), + content=self.loading_message, + ) + + if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user][0]['content']: + return response_xml + else: + message_data = await self.get_message(xml_msg) + + if message_data: + event = OAEvent.from_payload(message_data) + if event: + self.user_msg_queue.setdefault(from_user, []).append( + { + 'content': event.message, + } + ) + await self._handle_message(event) + + return response_xml + + except Exception: + await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}') + traceback.print_exc() + + async def get_message(self, xml_msg: str): + root = ET.fromstring(xml_msg) + + message_data = { + 'ToUserName': root.find('ToUserName').text, + 'FromUserName': root.find('FromUserName').text, + 'CreateTime': int(root.find('CreateTime').text), + 'MsgType': root.find('MsgType').text, + 'Content': root.find('Content').text if root.find('Content') is not None else None, + 'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None, + } + + return message_data + + async def run_task(self, host: str, port: int, *args, **kwargs): + """ + 启动 Quart 应用。 + """ + await self.app.run_task(host=host, port=port, *args, **kwargs) + + def on_message(self, msg_type: str): + """ + 注册消息类型处理器。 + """ + + def decorator(func: Callable[[OAEvent], None]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + + return decorator + + async def _handle_message(self, event: OAEvent): + """ + 处理消息事件。 + """ + + msg_type = event.type + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + + async def set_message(self, from_user: int, message_id: int, content: str): + if from_user not in self.msg_queue: + self.msg_queue[from_user] = [] + + self.msg_queue[from_user].append( + { + 'msg_id': message_id, + 'content': content, + } + ) diff --git a/src/langbot/libs/official_account_api/oaevent.py b/src/langbot/libs/official_account_api/oaevent.py new file mode 100644 index 0000000..d4de391 --- /dev/null +++ b/src/langbot/libs/official_account_api/oaevent.py @@ -0,0 +1,166 @@ +from typing import Dict, Any, Optional + + +class OAEvent(dict): + """ + 封装从微信公众号收到的事件数据对象(字典),提供属性以获取其中的字段。 + + 除 `type` 和 `detail_type` 属性对于任何事件都有效外,其它属性是否存在(若不存在则返回 `None`)依事件类型不同而不同。 + """ + + @staticmethod + def from_payload(payload: Dict[str, Any]) -> Optional['OAEvent']: + """ + 从微信公众号事件数据构造 `WecomEvent` 对象。 + + Args: + payload (Dict[str, Any]): 解密后的微信事件数据。 + + Returns: + Optional[OAEvent]: 如果事件数据合法,则返回 OAEvent 对象;否则返回 None。 + """ + try: + event = OAEvent(payload) + _ = event.type, event.detail_type # 确保必须字段存在 + return event + except KeyError: + return None + + @property + def type(self) -> str: + """ + 事件类型,例如 "message"、"event"、"text" 等。 + + Returns: + str: 事件类型。 + """ + return self.get('MsgType', '') + + @property + def picurl(self) -> str: + """ + 图片链接 + """ + return self.get('PicUrl', '') + + @property + def detail_type(self) -> str: + """ + 事件详细类型,依 `type` 的不同而不同。例如: + - 消息事件: "text", "image", "voice", 等 + - 事件通知: "subscribe", "unsubscribe", "click", 等 + + Returns: + str: 事件详细类型。 + """ + if self.type == 'event': + return self.get('Event', '') + return self.type + + @property + def name(self) -> str: + """ + 事件名,对于消息事件是 `type.detail_type`,对于其他事件是 `event_type`。 + + Returns: + str: 事件名。 + """ + return f'{self.type}.{self.detail_type}' + + @property + def user_id(self) -> Optional[str]: + """ + 发送方账号 + """ + return self.get('FromUserName') + + @property + def receiver_id(self) -> Optional[str]: + """ + 接收者 ID,例如机器人自身的公众号微信 ID。 + + Returns: + Optional[str]: 接收者 ID。 + """ + return self.get('ToUserName') + + @property + def message_id(self) -> Optional[str]: + """ + 消息 ID,仅在消息类型事件中存在。 + + Returns: + Optional[str]: 消息 ID。 + """ + return self.get('MsgId') + + @property + def message(self) -> Optional[str]: + """ + 消息内容,仅在消息类型事件中存在。 + + Returns: + Optional[str]: 消息内容。 + """ + return self.get('Content') + + @property + def media_id(self) -> Optional[str]: + """ + 媒体文件 ID,仅在图片、语音等消息类型中存在。 + + Returns: + Optional[str]: 媒体文件 ID。 + """ + return self.get('MediaId') + + @property + def timestamp(self) -> Optional[int]: + """ + 事件发生的时间戳。 + + Returns: + Optional[int]: 时间戳。 + """ + return self.get('CreateTime') + + @property + def event_key(self) -> Optional[str]: + """ + 事件的 Key 值,例如点击菜单时的 `EventKey`。 + + Returns: + Optional[str]: 事件 Key。 + """ + return self.get('EventKey') + + def __getattr__(self, key: str) -> Optional[Any]: + """ + 允许通过属性访问数据中的任意字段。 + + Args: + key (str): 字段名。 + + Returns: + Optional[Any]: 字段值。 + """ + return self.get(key) + + def __setattr__(self, key: str, value: Any) -> None: + """ + 允许通过属性设置数据中的任意字段。 + + Args: + key (str): 字段名。 + value (Any): 字段值。 + """ + self[key] = value + + def __repr__(self) -> str: + """ + 生成事件对象的字符串表示。 + + Returns: + str: 字符串表示。 + """ + return f'' diff --git a/src/langbot/libs/openclaw_weixin_api/__init__.py b/src/langbot/libs/openclaw_weixin_api/__init__.py new file mode 100644 index 0000000..a8b7c94 --- /dev/null +++ b/src/langbot/libs/openclaw_weixin_api/__init__.py @@ -0,0 +1,3 @@ +from .client import OpenClawWeixinClient as OpenClawWeixinClient +from .types import ApiError as ApiError +from .types import LoginResult as LoginResult diff --git a/src/langbot/libs/openclaw_weixin_api/client.py b/src/langbot/libs/openclaw_weixin_api/client.py new file mode 100644 index 0000000..d713f29 --- /dev/null +++ b/src/langbot/libs/openclaw_weixin_api/client.py @@ -0,0 +1,807 @@ +"""Async HTTP client for the OpenClaw WeChat API. + +Implements the iLink Bot API protocol. +Reference: https://github.com/epiral/weixin-bot + +Endpoints: getUpdates (long-poll), sendMessage, getUploadUrl, getConfig, sendTyping. +""" + +from __future__ import annotations + +import asyncio +import base64 +import io +import logging +import os +import struct +import typing +import uuid +from typing import Optional +from urllib.parse import quote + +import aiohttp + +from .types import ( + ApiError, + CDNMedia, + FileItem, + GetConfigResponse, + GetUpdatesResponse, + GetUploadUrlResponse, + ImageItem, + LoginResult, + MessageItem, + QRCodeResponse, + QRStatusResponse, + RefMessage, + TextItem, + VideoItem, + VoiceItem, + WeixinMessage, +) + +logger = logging.getLogger('openclaw-weixin-sdk') + +DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com' +CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c' + +CHANNEL_VERSION = '1.0.0' + +DEFAULT_API_TIMEOUT = 15 +DEFAULT_LONG_POLL_TIMEOUT = 40 +DEFAULT_CONFIG_TIMEOUT = 10 +DEFAULT_QR_POLL_TIMEOUT = 35 + +SESSION_EXPIRED_ERRCODE = -14 + +DEFAULT_BOT_TYPE = '3' + +# Maximum text length per message chunk (WeChat limit) +MAX_TEXT_CHUNK_SIZE = 2000 + + +def _random_wechat_uin() -> str: + """Generate the X-WECHAT-UIN header: random uint32 -> decimal string -> base64.""" + rand_bytes = os.urandom(4) + uint32_val = struct.unpack('>I', rand_bytes)[0] + return base64.b64encode(str(uint32_val).encode('utf-8')).decode('utf-8') + + +def _build_base_info() -> dict: + """Build the base_info payload included in every API request.""" + return {'channel_version': CHANNEL_VERSION} + + +def _chunk_text(text: str, max_size: int = MAX_TEXT_CHUNK_SIZE) -> list[str]: + """Split long text into chunks that fit within WeChat's message size limit.""" + if len(text) <= max_size: + return [text] + chunks = [] + while text: + chunks.append(text[:max_size]) + text = text[max_size:] + return chunks + + +class OpenClawWeixinClient: + """Async client for the OpenClaw WeChat HTTP JSON API.""" + + def __init__(self, base_url: str, token: str): + self.base_url = base_url.rstrip('/') + self.token = token + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def close(self): + if self._session and not self._session.closed: + await self._session.close() + + def _build_headers(self) -> dict[str, str]: + headers = { + 'Content-Type': 'application/json', + 'AuthorizationType': 'ilink_bot_token', + 'X-WECHAT-UIN': _random_wechat_uin(), + } + if self.token: + headers['Authorization'] = f'Bearer {self.token}' + return headers + + async def _post(self, endpoint: str, payload: dict, timeout: float = DEFAULT_API_TIMEOUT) -> dict: + """Make a POST request and return the JSON response. + + Raises ApiError on HTTP errors or when the response contains a non-zero errcode. + """ + payload['base_info'] = _build_base_info() + + session = await self._get_session() + url = f'{self.base_url}/{endpoint}' + headers = self._build_headers() + + async with session.post( + url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout) + ) as resp: + if resp.status != 200: + text = await resp.text() + raise ApiError( + f'OpenClaw API error {resp.status}: {text}', + status=resp.status, + ) + data = await resp.json(content_type=None) + + # Check for application-level errors in the response body + errcode = data.get('errcode') or data.get('ret') + if errcode and errcode != 0: + raise ApiError( + data.get('errmsg') or f'API errcode {errcode}', + status=200, + code=errcode, + payload=data, + ) + + return data + + async def get_updates( + self, get_updates_buf: str = '', timeout: float = DEFAULT_LONG_POLL_TIMEOUT + ) -> GetUpdatesResponse: + """Long-poll for new messages. + + Note: This method does NOT raise ApiError for errcode responses — + it returns them in the GetUpdatesResponse so the caller can handle + session expiry and other errors with full context. + """ + try: + # Bypass the errcode check in _post since get_updates needs + # to return error info (e.g. session expired) to the caller. + payload: dict = {'get_updates_buf': get_updates_buf} + payload['base_info'] = _build_base_info() + + session = await self._get_session() + url = f'{self.base_url}/ilink/bot/getupdates' + headers = self._build_headers() + + async with session.post( + url, + json=payload, + headers=headers, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as resp: + if resp.status != 200: + text = await resp.text() + raise ApiError( + f'OpenClaw API error {resp.status}: {text}', + status=resp.status, + ) + data = await resp.json(content_type=None) + + except (asyncio.TimeoutError, aiohttp.ServerTimeoutError): + return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf) + except ApiError: + raise + except Exception as e: + if 'timeout' in str(e).lower(): + return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf) + raise + + return _parse_get_updates_response(data) + + async def send_message( + self, + to_user_id: str, + item_list: list[MessageItem], + context_token: str = '', + ) -> None: + """Send a message to a user.""" + items_payload = [_message_item_to_dict(item) for item in item_list] + + payload = { + 'msg': { + 'from_user_id': '', + 'to_user_id': to_user_id, + 'client_id': f'langbot-{uuid.uuid4().hex[:16]}', + 'message_type': WeixinMessage.TYPE_BOT, + 'message_state': WeixinMessage.STATE_FINISH, + 'item_list': items_payload, + 'context_token': context_token or None, + } + } + await self._post('ilink/bot/sendmessage', payload) + + async def send_text(self, to_user_id: str, text: str, context_token: str = '') -> None: + """Send a plain text message, automatically chunking if too long.""" + chunks = _chunk_text(text) + for chunk in chunks: + item = MessageItem(type=MessageItem.TEXT, text_item=TextItem(text=chunk)) + await self.send_message(to_user_id, [item], context_token) + + async def get_config(self, ilink_user_id: str, context_token: str = '') -> GetConfigResponse: + """Get bot config including typing_ticket.""" + data = await self._post( + 'ilink/bot/getconfig', + {'ilink_user_id': ilink_user_id, 'context_token': context_token or None}, + timeout=DEFAULT_CONFIG_TIMEOUT, + ) + return GetConfigResponse( + ret=data.get('ret'), + errmsg=data.get('errmsg'), + typing_ticket=data.get('typing_ticket'), + ) + + async def send_typing(self, ilink_user_id: str, typing_ticket: str, status: int = 1) -> None: + """Send typing indicator. status: 1=typing, 2=cancel.""" + await self._post( + 'ilink/bot/sendtyping', + { + 'ilink_user_id': ilink_user_id, + 'typing_ticket': typing_ticket, + 'status': status, + }, + timeout=DEFAULT_CONFIG_TIMEOUT, + ) + + async def stop_typing(self, ilink_user_id: str, typing_ticket: str) -> None: + """Cancel the typing indicator for a user.""" + await self.send_typing(ilink_user_id, typing_ticket, status=2) + + async def download_media( + self, + media: CDNMedia, + ) -> bytes: + """Download and decrypt a file from the WeChat CDN. + + Args: + media: CDNMedia object with encrypt_query_param and aes_key. + + Returns: + Decrypted file bytes. + """ + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.padding import PKCS7 + + if not media.encrypt_query_param: + raise ApiError('CDN media has no encrypt_query_param', status=0) + if not media.aes_key: + raise ApiError('CDN media has no aes_key', status=0) + + # Derive 16-byte AES key + # aes_key is base64-encoded; the decoded content may be: + # - raw 16 bytes (direct AES key) + # - 32-char hex string (decode hex to get 16 bytes) + raw = base64.b64decode(media.aes_key) + if len(raw) == 16: + aes_key = raw + elif len(raw) == 32: + # Hex-encoded 16-byte key + aes_key = bytes.fromhex(raw.decode('utf-8')) + else: + raise ApiError(f'Invalid AES key length: {len(raw)} (expected 16 or 32)', status=0) + + # Download encrypted bytes from CDN + session = await self._get_session() + cdn_url = f'{CDN_BASE_URL}/download?encrypted_query_param={quote(media.encrypt_query_param, safe="")}' + + async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp: + if resp.status != 200: + text = await resp.text() + raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status) + encrypted = await resp.read() + + # Decrypt AES-128-ECB with PKCS7 padding + cipher = Cipher(algorithms.AES(aes_key), modes.ECB()) + decryptor = cipher.decryptor() + padded = decryptor.update(encrypted) + decryptor.finalize() + + unpadder = PKCS7(128).unpadder() + return unpadder.update(padded) + unpadder.finalize() + + async def upload_media( + self, + file_bytes: bytes, + to_user_id: str, + media_type: int, + ) -> CDNMedia: + """Encrypt and upload media to WeChat CDN. + + Args: + file_bytes: Raw file bytes to upload. + to_user_id: Recipient user ID. + media_type: 1=IMAGE, 2=VIDEO, 3=FILE, 4=VOICE. + + Returns: + CDNMedia with encrypt_query_param and aes_key for use in sendMessage. + """ + import hashlib + + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.padding import PKCS7 + + # 1. Generate random 16-byte AES key + raw_key = os.urandom(16) + aes_key_hex = raw_key.hex() # 32-char hex string + + # 2. Encode key for CDNMedia: base64(hex_string) — same for all media types + # Matches official SDK: Buffer.from(aeskey_hex).toString("base64") + encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8') + + # 3. Encrypt file with AES-128-ECB + PKCS7 + padder = PKCS7(128).padder() + padded = padder.update(file_bytes) + padder.finalize() + cipher = Cipher(algorithms.AES(raw_key), modes.ECB()) + encryptor = cipher.encryptor() + encrypted = encryptor.update(padded) + encryptor.finalize() + + # 4. Get upload URL + raw_md5 = hashlib.md5(file_bytes).hexdigest() + filekey = os.urandom(16).hex() # 32-char hex, matches official SDK + + upload_resp = await self.get_upload_url( + filekey=filekey, + media_type=media_type, + to_user_id=to_user_id, + rawsize=len(file_bytes), + rawfilemd5=raw_md5, + filesize=len(encrypted), + aeskey=aes_key_hex, # hex string, as expected by the API + ) + + if not upload_resp.upload_param: + raise ApiError('Failed to get upload URL', status=0) + + # 5. Upload to CDN + # upload_param is an opaque token from the server — pass it as-is + session = await self._get_session() + cdn_url = f'{CDN_BASE_URL}/upload?encrypted_query_param={quote(upload_resp.upload_param, safe="")}&filekey={quote(filekey, safe="")}' + logger.debug( + 'CDN upload: url=%s raw_size=%d encrypted_size=%d md5=%s aeskey=%s', + cdn_url, + len(file_bytes), + len(encrypted), + raw_md5, + encoded_key, + ) + + async with session.post( + cdn_url, + data=encrypted, + headers={'Content-Type': 'application/octet-stream'}, + timeout=aiohttp.ClientTimeout(total=120), + ) as resp: + if resp.status != 200: + text = await resp.text() + logger.error('CDN upload failed: status=%d url=%s body=%s', resp.status, cdn_url, text[:500]) + raise ApiError(f'CDN upload failed: {resp.status} {text}', status=resp.status) + download_param = resp.headers.get('x-encrypted-param', '') + + if not download_param: + raise ApiError('CDN upload succeeded but no x-encrypted-param returned', status=0) + + return CDNMedia( + encrypt_query_param=download_param, + aes_key=encoded_key, + encrypt_type=1, + ) + + async def send_image( + self, + to_user_id: str, + image_bytes: bytes, + context_token: str = '', + ) -> None: + """Upload an image to CDN and send it.""" + media = await self.upload_media(image_bytes, to_user_id, media_type=1) + item = MessageItem( + type=MessageItem.IMAGE, + image_item=ImageItem( + media=media, + aeskey=media.aes_key, + ), + ) + await self.send_message(to_user_id, [item], context_token) + + async def send_file( + self, + to_user_id: str, + file_bytes: bytes, + file_name: str, + context_token: str = '', + ) -> None: + """Upload a file to CDN and send it.""" + import hashlib + + media = await self.upload_media(file_bytes, to_user_id, media_type=3) + item = MessageItem( + type=MessageItem.FILE, + file_item=FileItem( + media=media, + file_name=file_name, + md5=hashlib.md5(file_bytes).hexdigest(), + len=str(len(file_bytes)), + ), + ) + await self.send_message(to_user_id, [item], context_token) + + async def send_voice( + self, + to_user_id: str, + voice_bytes: bytes, + playtime: int = 0, + context_token: str = '', + ) -> None: + """Upload a voice message to CDN and send it.""" + media = await self.upload_media(voice_bytes, to_user_id, media_type=4) + item = MessageItem( + type=MessageItem.VOICE, + voice_item=VoiceItem( + media=media, + playtime=playtime, + ), + ) + await self.send_message(to_user_id, [item], context_token) + + async def get_upload_url( + self, + filekey: str, + media_type: int, + to_user_id: str, + rawsize: int, + rawfilemd5: str, + filesize: int, + thumb_rawsize: Optional[int] = None, + thumb_rawfilemd5: Optional[str] = None, + thumb_filesize: Optional[int] = None, + aeskey: Optional[str] = None, + ) -> GetUploadUrlResponse: + """Get a pre-signed CDN upload URL.""" + payload: dict = { + 'filekey': filekey, + 'media_type': media_type, + 'to_user_id': to_user_id, + 'rawsize': rawsize, + 'rawfilemd5': rawfilemd5, + 'filesize': filesize, + 'no_need_thumb': True, + } + if thumb_rawsize is not None: + payload['thumb_rawsize'] = thumb_rawsize + if thumb_rawfilemd5 is not None: + payload['thumb_rawfilemd5'] = thumb_rawfilemd5 + if thumb_filesize is not None: + payload['thumb_filesize'] = thumb_filesize + if aeskey is not None: + payload['aeskey'] = aeskey + + data = await self._post('ilink/bot/getuploadurl', payload) + logger.debug('get_upload_url response: %s', data) + return GetUploadUrlResponse( + upload_param=data.get('upload_param'), + thumb_upload_param=data.get('thumb_upload_param'), + ) + + # ----------------------------------------------------------------------- + # QR Code Login + # ----------------------------------------------------------------------- + + async def fetch_qrcode(self, bot_type: str = DEFAULT_BOT_TYPE) -> QRCodeResponse: + """Fetch a QR code for WeChat login authorization (GET, no auth needed).""" + session = await self._get_session() + url = f'{self.base_url}/ilink/bot/get_bot_qrcode?bot_type={bot_type}' + + async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp: + if resp.status != 200: + text = await resp.text() + raise ApiError( + f'Failed to fetch QR code: {resp.status} {text}', + status=resp.status, + ) + data = await resp.json(content_type=None) + + logger.debug( + 'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content')) + ) + + return QRCodeResponse( + qrcode=data.get('qrcode'), + qrcode_img_content=data.get('qrcode_img_content'), + ) + + async def _fetch_qr_image_base64(self, url: str) -> str: + """Generate a QR code image from the URL and return a data URI string. + + The qrcode_img_content URL points to an HTML page (not a raw image), + so we generate the QR code locally using the qrcode library. + """ + import qrcode + + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(fill_color='black', back_color='white') + + buf = io.BytesIO() + img.save(buf, format='PNG') + b64 = base64.b64encode(buf.getvalue()).decode('utf-8') + return f'data:image/png;base64,{b64}' + + async def poll_qrcode_status(self, qrcode: str) -> QRStatusResponse: + """Long-poll the QR code scan status (GET with iLink-App-ClientVersion header).""" + session = await self._get_session() + url = f'{self.base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe="")}' + headers = {'iLink-App-ClientVersion': '1'} + + try: + async with session.get( + url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT) + ) as resp: + if resp.status != 200: + text = await resp.text() + raise ApiError( + f'Failed to poll QR status: {resp.status} {text}', + status=resp.status, + ) + data = await resp.json(content_type=None) + logger.debug('QR status poll response: %s', data) + except (asyncio.TimeoutError, aiohttp.ServerTimeoutError): + return QRStatusResponse(status='wait') + + return QRStatusResponse( + status=data.get('status'), + bot_token=data.get('bot_token'), + ilink_bot_id=data.get('ilink_bot_id'), + baseurl=data.get('baseurl'), + ilink_user_id=data.get('ilink_user_id'), + ) + + async def login( + self, + max_retries: int = 5, + poll_timeout_ms: int = 480_000, + on_qrcode: Optional[typing.Callable[[str, str], typing.Any]] = None, + on_status: Optional[typing.Callable[[str], typing.Any]] = None, + ) -> LoginResult: + """Complete QR code login flow with auto-retry on expiry. + + Args: + max_retries: Max number of QR code refreshes on expiry. + poll_timeout_ms: Timeout per QR code in milliseconds. + on_qrcode: Callback(qr_image_base64, qr_url) called each time a + new QR code is fetched. Use this to display the QR code. + on_status: Callback(status_str) called on each status poll change. + + Returns: + LoginResult with token, base_url, and account_id. + + Raises: + ApiError: On unrecoverable API errors. + Exception: If all retries are exhausted. + """ + last_qr_base64: Optional[str] = None + + for attempt in range(max_retries): + qr_resp = await self.fetch_qrcode() + if not qr_resp.qrcode or not qr_resp.qrcode_img_content: + raise ApiError('Failed to get QR code from server', status=0) + + # Convert QR image to base64 and notify caller + last_qr_base64 = await self._fetch_qr_image_base64(qr_resp.qrcode_img_content) + if on_qrcode: + try: + result = on_qrcode(last_qr_base64, qr_resp.qrcode_img_content) + if asyncio.iscoroutine(result) or asyncio.isfuture(result): + await result + except Exception as e: + logger.warning('on_qrcode callback error: %s', e) + + # Poll until confirmed / expired / timeout + loop = asyncio.get_running_loop() + deadline = loop.time() + poll_timeout_ms / 1000.0 + + while loop.time() < deadline: + try: + status_resp = await self.poll_qrcode_status(qr_resp.qrcode) + except Exception as e: + logger.error('Error polling QR status: %s', e) + await asyncio.sleep(2) + continue + + if on_status: + try: + cb_result = on_status(status_resp.status or 'unknown') + if asyncio.iscoroutine(cb_result) or asyncio.isfuture(cb_result): + await cb_result + except Exception as e: + logger.warning('on_status callback error: %s', e) + + if status_resp.status == 'confirmed' and status_resp.bot_token: + new_base_url = status_resp.baseurl or self.base_url + # Update this client instance as well + self.token = status_resp.bot_token + self.base_url = new_base_url.rstrip('/') + return LoginResult( + token=status_resp.bot_token, + base_url=new_base_url, + account_id=status_resp.ilink_bot_id or '', + qr_image_base64=last_qr_base64, + ) + + if status_resp.status == 'expired': + break # retry with a new QR code + + await asyncio.sleep(1) + else: + # While-loop ended without break → poll timeout, treat as expired + pass + + remaining = max_retries - attempt - 1 + if remaining > 0: + logger.info('QR code expired, refreshing... (%d retries left)', remaining) + else: + raise ApiError('QR code login failed: max retries exceeded', status=0) + + # Should not reach here, but just in case + raise ApiError('QR code login failed', status=0) + + +# --------------------------------------------------------------------------- +# Parsing helpers +# --------------------------------------------------------------------------- + + +def _parse_cdn_media(data: Optional[dict]) -> Optional[CDNMedia]: + if not data: + return None + return CDNMedia( + encrypt_query_param=data.get('encrypt_query_param'), + aes_key=data.get('aes_key'), + encrypt_type=data.get('encrypt_type'), + ) + + +def _parse_message_item(data: dict) -> MessageItem: + item = MessageItem( + type=data.get('type'), + create_time_ms=data.get('create_time_ms'), + update_time_ms=data.get('update_time_ms'), + is_completed=data.get('is_completed'), + msg_id=data.get('msg_id'), + ) + + if data.get('text_item'): + item.text_item = TextItem(text=data['text_item'].get('text')) + + if data.get('image_item'): + img = data['image_item'] + item.image_item = ImageItem( + media=_parse_cdn_media(img.get('media')), + thumb_media=_parse_cdn_media(img.get('thumb_media')), + aeskey=img.get('aeskey'), + url=img.get('url'), + mid_size=img.get('mid_size'), + ) + + if data.get('voice_item'): + v = data['voice_item'] + item.voice_item = VoiceItem( + media=_parse_cdn_media(v.get('media')), + encode_type=v.get('encode_type'), + playtime=v.get('playtime'), + text=v.get('text'), + ) + + if data.get('file_item'): + f = data['file_item'] + item.file_item = FileItem( + media=_parse_cdn_media(f.get('media')), + file_name=f.get('file_name'), + md5=f.get('md5'), + len=f.get('len'), + ) + + if data.get('video_item'): + vid = data['video_item'] + item.video_item = VideoItem( + media=_parse_cdn_media(vid.get('media')), + video_size=vid.get('video_size'), + play_length=vid.get('play_length'), + video_md5=vid.get('video_md5'), + thumb_media=_parse_cdn_media(vid.get('thumb_media')), + ) + + if data.get('ref_msg'): + ref = data['ref_msg'] + item.ref_msg = RefMessage( + title=ref.get('title'), + message_item=_parse_message_item(ref['message_item']) if ref.get('message_item') else None, + ) + + return item + + +def _parse_weixin_message(data: dict) -> WeixinMessage: + msg = WeixinMessage( + seq=data.get('seq'), + message_id=data.get('message_id'), + from_user_id=data.get('from_user_id'), + to_user_id=data.get('to_user_id'), + client_id=data.get('client_id'), + create_time_ms=data.get('create_time_ms'), + session_id=data.get('session_id'), + group_id=data.get('group_id'), + message_type=data.get('message_type'), + message_state=data.get('message_state'), + context_token=data.get('context_token'), + ) + if data.get('item_list'): + msg.item_list = [_parse_message_item(item) for item in data['item_list']] + return msg + + +def _parse_get_updates_response(data: dict) -> GetUpdatesResponse: + resp = GetUpdatesResponse( + ret=data.get('ret'), + errcode=data.get('errcode'), + errmsg=data.get('errmsg'), + get_updates_buf=data.get('get_updates_buf'), + longpolling_timeout_ms=data.get('longpolling_timeout_ms'), + ) + if data.get('msgs'): + resp.msgs = [_parse_weixin_message(m) for m in data['msgs']] + return resp + + +def _cdn_media_to_dict(media: Optional[CDNMedia]) -> Optional[dict]: + if not media: + return None + d: dict = {} + if media.encrypt_query_param is not None: + d['encrypt_query_param'] = media.encrypt_query_param + if media.aes_key is not None: + d['aes_key'] = media.aes_key + if media.encrypt_type is not None: + d['encrypt_type'] = media.encrypt_type + return d or None + + +def _message_item_to_dict(item: MessageItem) -> dict: + d: dict = {'type': item.type} + + if item.text_item: + d['text_item'] = {'text': item.text_item.text} + + if item.image_item: + img_d: dict = {} + if item.image_item.media: + img_d['media'] = _cdn_media_to_dict(item.image_item.media) + if item.image_item.mid_size is not None: + img_d['mid_size'] = item.image_item.mid_size + d['image_item'] = img_d + + if item.voice_item: + voice_d: dict = {} + if item.voice_item.media: + voice_d['media'] = _cdn_media_to_dict(item.voice_item.media) + if item.voice_item.playtime is not None: + voice_d['playtime'] = item.voice_item.playtime + d['voice_item'] = voice_d + + if item.file_item: + file_d: dict = {} + if item.file_item.media: + file_d['media'] = _cdn_media_to_dict(item.file_item.media) + if item.file_item.file_name: + file_d['file_name'] = item.file_item.file_name + if item.file_item.len: + file_d['len'] = item.file_item.len + d['file_item'] = file_d + + if item.video_item: + vid_d: dict = {} + if item.video_item.media: + vid_d['media'] = _cdn_media_to_dict(item.video_item.media) + if item.video_item.video_size is not None: + vid_d['video_size'] = item.video_item.video_size + d['video_item'] = vid_d + + return d diff --git a/src/langbot/libs/openclaw_weixin_api/types.py b/src/langbot/libs/openclaw_weixin_api/types.py new file mode 100644 index 0000000..93bb2e3 --- /dev/null +++ b/src/langbot/libs/openclaw_weixin_api/types.py @@ -0,0 +1,200 @@ +"""Type definitions for the OpenClaw WeChat API, mirroring the upstream protocol.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + +SESSION_EXPIRED_ERRCODE = -14 + + +class ApiError(Exception): + """Structured error raised by the OpenClaw WeChat API.""" + + def __init__( + self, + message: str, + *, + status: int = 0, + code: int | None = None, + payload: Any = None, + ): + super().__init__(message) + self.status = status + self.code = code + self.payload = payload + + @property + def is_session_expired(self) -> bool: + return self.code == SESSION_EXPIRED_ERRCODE + + +@dataclass +class CDNMedia: + encrypt_query_param: Optional[str] = None + aes_key: Optional[str] = None + encrypt_type: Optional[int] = None + + +@dataclass +class TextItem: + text: Optional[str] = None + + +@dataclass +class ImageItem: + media: Optional[CDNMedia] = None + thumb_media: Optional[CDNMedia] = None + aeskey: Optional[str] = None + url: Optional[str] = None + mid_size: Optional[int] = None + thumb_size: Optional[int] = None + thumb_height: Optional[int] = None + thumb_width: Optional[int] = None + hd_size: Optional[int] = None + _downloaded_bytes: Optional[bytes] = field(default=None, repr=False) + + +@dataclass +class VoiceItem: + media: Optional[CDNMedia] = None + encode_type: Optional[int] = None + bits_per_sample: Optional[int] = None + sample_rate: Optional[int] = None + playtime: Optional[int] = None + text: Optional[str] = None + _downloaded_bytes: Optional[bytes] = field(default=None, repr=False) + + +@dataclass +class FileItem: + media: Optional[CDNMedia] = None + file_name: Optional[str] = None + md5: Optional[str] = None + len: Optional[str] = None + _downloaded_bytes: Optional[bytes] = field(default=None, repr=False) + + +@dataclass +class VideoItem: + media: Optional[CDNMedia] = None + video_size: Optional[int] = None + play_length: Optional[int] = None + video_md5: Optional[str] = None + thumb_media: Optional[CDNMedia] = None + thumb_size: Optional[int] = None + thumb_height: Optional[int] = None + thumb_width: Optional[int] = None + _downloaded_bytes: Optional[bytes] = field(default=None, repr=False) + + +@dataclass +class RefMessage: + message_item: Optional[MessageItem] = None + title: Optional[str] = None + + +@dataclass +class MessageItem: + """A single content item inside a WeixinMessage.""" + + # Item types + NONE = 0 + TEXT = 1 + IMAGE = 2 + VOICE = 3 + FILE = 4 + VIDEO = 5 + + type: Optional[int] = None + create_time_ms: Optional[int] = None + update_time_ms: Optional[int] = None + is_completed: Optional[bool] = None + msg_id: Optional[str] = None + ref_msg: Optional[RefMessage] = None + text_item: Optional[TextItem] = None + image_item: Optional[ImageItem] = None + voice_item: Optional[VoiceItem] = None + file_item: Optional[FileItem] = None + video_item: Optional[VideoItem] = None + + +@dataclass +class WeixinMessage: + """Unified message from getUpdates or for sendMessage.""" + + # Message types + TYPE_USER = 1 + TYPE_BOT = 2 + + # Message states + STATE_NEW = 0 + STATE_GENERATING = 1 + STATE_FINISH = 2 + + seq: Optional[int] = None + message_id: Optional[int] = None + from_user_id: Optional[str] = None + to_user_id: Optional[str] = None + client_id: Optional[str] = None + create_time_ms: Optional[int] = None + update_time_ms: Optional[int] = None + delete_time_ms: Optional[int] = None + session_id: Optional[str] = None + group_id: Optional[str] = None + message_type: Optional[int] = None + message_state: Optional[int] = None + item_list: Optional[list[MessageItem]] = None + context_token: Optional[str] = None + + +@dataclass +class GetUpdatesResponse: + ret: Optional[int] = None + errcode: Optional[int] = None + errmsg: Optional[str] = None + msgs: list[WeixinMessage] = field(default_factory=list) + get_updates_buf: Optional[str] = None + longpolling_timeout_ms: Optional[int] = None + + +@dataclass +class GetConfigResponse: + ret: Optional[int] = None + errmsg: Optional[str] = None + typing_ticket: Optional[str] = None + + +@dataclass +class GetUploadUrlResponse: + upload_param: Optional[str] = None + thumb_upload_param: Optional[str] = None + + +@dataclass +class QRCodeResponse: + """Response from get_bot_qrcode endpoint.""" + + qrcode: Optional[str] = None + qrcode_img_content: Optional[str] = None + + +@dataclass +class QRStatusResponse: + """Response from get_qrcode_status endpoint.""" + + status: Optional[str] = None # "wait" | "scaned" | "confirmed" | "expired" + bot_token: Optional[str] = None + ilink_bot_id: Optional[str] = None + baseurl: Optional[str] = None + ilink_user_id: Optional[str] = None + + +@dataclass +class LoginResult: + """Result returned by the login flow.""" + + token: str + base_url: str + account_id: str + qr_image_base64: Optional[str] = None # data URI of the last QR code shown diff --git a/src/langbot/libs/qq_official_api/__init__.py b/src/langbot/libs/qq_official_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py new file mode 100644 index 0000000..0ba1917 --- /dev/null +++ b/src/langbot/libs/qq_official_api/api.py @@ -0,0 +1,1162 @@ +import re +import time +import asyncio +from quart import request +import httpx +from quart import Quart +from typing import Callable, Dict, Any, Optional +import langbot_plugin.api.entities.builtin.platform.events as platform_events +from .qqofficialevent import QQOfficialEvent +import json +import traceback +from cryptography.hazmat.primitives.asymmetric import ed25519 + + +QQ_SELECT_ACTION_PREFIX = '__langbot_select__:' + + +def get_select_field_options(form_data: dict) -> tuple[str, list[str]]: + """Return the active select field name and its display/submission values.""" + field_name = str(form_data.get('_current_input_field') or '').strip() + if not field_name: + return '', [] + + field = next( + ( + item + for item in form_data.get('input_defs') or [] + if str(item.get('output_variable_name') or '').strip() == field_name + ), + None, + ) + if not field or str(field.get('type') or '').strip().lower() != 'select': + return '', [] + + source = field.get('option_source') or {} + source_value = source.get('value') if isinstance(source, dict) else None + if isinstance(source_value, list): + return field_name, [str(item) for item in source_value] + if isinstance(source_value, str): + return field_name, [part.strip() for part in source_value.splitlines() if part.strip()] + + options = field.get('options') + if not isinstance(options, list): + return field_name, [] + values = [] + for item in options: + if isinstance(item, dict): + values.append(str(item.get('label') or item.get('value') or '')) + else: + values.append(str(item)) + return field_name, [value for value in values if value] + + +def build_keyboard_from_select_field(form_data: dict, *, buttons_per_row: int | None = None) -> dict: + """Build callback buttons for the currently active Dify select field.""" + _, options = get_select_field_options(form_data) + visible_options = options[:25] + if buttons_per_row is None: + # Keep small choices readable while fitting up to QQ's 5x5 limit. + buttons_per_row = min(5, max(2, (len(visible_options) + 4) // 5)) + selection_actions = [ + { + 'id': f'{QQ_SELECT_ACTION_PREFIX}{idx}', + 'title': option, + 'button_style': 'secondary', + } + for idx, option in enumerate(visible_options) + ] + return build_keyboard_from_form({'actions': selection_actions}, buttons_per_row=buttons_per_row) + + +def resolve_select_button_action(form_data: dict, action_id: str) -> tuple[str, str] | None: + """Resolve a select-button callback to ``(field_name, option_value)``.""" + if not action_id.startswith(QQ_SELECT_ACTION_PREFIX): + return None + try: + option_index = int(action_id[len(QQ_SELECT_ACTION_PREFIX) :]) + except ValueError: + return None + + field_name, options = get_select_field_options(form_data) + if not field_name or option_index < 0 or option_index >= len(options) or option_index >= 25: + return None + return field_name, options[option_index] + + +def build_keyboard_from_form(form_data: dict, *, buttons_per_row: int = 2) -> dict: + """Build a QQ keyboard JSON payload from a Dify human-input form_data. + + Each Dify ``action`` becomes a callback button (``action.type=1``) + whose ``data`` is set directly to the Dify ``action_id``. The + INTERACTION_CREATE event carries this back as + ``data.resolved.button_data`` so the adapter can match the click to + the originating form. + + Layout limits per spec: max 5 rows, max 5 buttons per row. We default + to 2 buttons per row for legibility; oversized button lists wrap + onto additional rows and overflow gets dropped (max 25 visible). + + Args: + form_data: Dify ``{"actions": [{"id", "title", "button_style"}, ...]}``. + buttons_per_row: 1..5. Mobile UI looks best at 2. + + Returns: + ``{"content": {"rows": [{"buttons": [...]}]}}``. + """ + actions = list(form_data.get('actions') or [])[:25] # 5×5 hard cap + buttons_per_row = max(1, min(5, buttons_per_row)) + + def _button(idx: int, action: dict) -> dict: + action_id = str(action.get('id') or '') + label = str(action.get('title') or action_id or f'选项 {idx + 1}') + style_raw = (action.get('button_style') or '').lower() + # QQ: 0 灰色线框, 1 蓝色线框. Highlight the primary / first action. + if style_raw == 'primary' or (style_raw == '' and idx == 0): + style = 1 + else: + style = 0 + return { + 'id': str(idx + 1), + 'render_data': { + 'label': label, + # Shown after the user clicks — gives local "已选择" feedback + # without a follow-up message. Style mimics DingTalk/Lark's + # in-card selection state. + 'visited_label': f'✓ {label}', + 'style': style, + }, + 'action': { + 'type': 1, # callback button + 'permission': {'type': 2}, # everyone can click + 'data': action_id, + 'unsupport_tips': '当前客户端版本不支持此按钮,请升级 QQ', + }, + } + + rows = [] + for row_start in range(0, len(actions), buttons_per_row): + row_actions = actions[row_start : row_start + buttons_per_row] + rows.append( + { + 'buttons': [_button(row_start + j, a) for j, a in enumerate(row_actions)], + } + ) + if len(rows) >= 5: + break + + return {'content': {'rows': rows}} + + +class QQOfficialClient: + def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False): + self.unified_mode = unified_mode + self.app = Quart(__name__) + + # 只有在非统一模式下才注册独立路由 + if not self.unified_mode: + self.app.add_url_rule( + '/callback/command', + 'handle_callback', + self.handle_callback_request, + methods=['GET', 'POST'], + ) + + self.secret = secret + self.token = token + self.app_id = app_id + self._message_handlers = {} + # Single optional handler for INTERACTION_CREATE (button click). We + # don't multiplex like message handlers — only the adapter cares, + # and the click<->resume path needs a single source of truth. + self._interaction_handler: Optional[Callable[[Dict[str, Any], Optional[str]], Any]] = None + self.base_url = 'https://api.sgroup.qq.com' + self.access_token = '' + self.access_token_expiry_time = None + self.logger = logger + self._msg_seq_counter = 0 + self._token_refresh_task: Optional[asyncio.Task] = None + + async def check_access_token(self): + """检查access_token是否存在""" + if not self.access_token or await self.is_token_expired(): + return False + return bool(self.access_token and self.access_token.strip()) + + async def get_access_token(self): + """获取access_token""" + url = 'https://bots.qq.com/app/getAppAccessToken' + async with httpx.AsyncClient() as client: + params = { + 'appId': self.app_id, + 'clientSecret': self.secret, + } + headers = { + 'content-type': 'application/json', + } + response = await client.post(url, json=params, headers=headers) + if response.status_code != 200: + raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}') + response_data = response.json() + access_token = response_data.get('access_token') + expires_in = int(response_data.get('expires_in', 7200)) + self.access_token_expiry_time = time.time() + expires_in - 60 + if access_token: + self.access_token = access_token + await self.logger.info(f'access_token obtained, expires_in={expires_in}s') + else: + raise Exception('Failed to get access_token: no access_token in response') + + async def handle_callback_request(self): + """处理回调请求(独立端口模式,使用全局 request)""" + return await self._handle_callback_internal(request) + + async def handle_unified_webhook(self, req): + """处理回调请求(统一 webhook 模式,显式传递 request)。 + + Args: + req: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """处理回调请求的内部实现。 + + Args: + req: Quart Request 对象 + """ + try: + body = await req.get_data() + + await self.logger.info(f'Received request, body length: {len(body)}') + + if not body or len(body) == 0: + await self.logger.info('Received empty body, might be health check or GET request') + return {'code': 0, 'message': 'ok'}, 200 + + payload = json.loads(body) + + if payload.get('op') == 13: + validation_data = payload.get('d') + if not validation_data: + return {'error': "missing 'd' field"}, 400 + response = await self.verify(validation_data) + return response, 200 + + if payload.get('op') == 0: + # INTERACTION_CREATE (button click) skips ``get_message`` — + # that helper only flattens message-event fields and would + # drop ``data.resolved.button_data`` / ``data.button_id``. + if payload.get('t') == 'INTERACTION_CREATE': + if self._interaction_handler: + try: + d = payload.get('d') or {} + # Top-level ``id`` is the ws/event id used as + # ``event_id`` for passive replies. ``d.id`` + # is the interaction id used for ACK. Do not + # confuse the two — QQ rejects misuse with + # 40034025. + ws_event_id = payload.get('id') + await self._interaction_handler(d, ws_event_id) + except Exception: + await self.logger.error(f'Error in interaction handler: {traceback.format_exc()}') + return {'code': 0, 'message': 'success'} + message_data = await self.get_message(payload) + if message_data: + event = QQOfficialEvent.from_payload(message_data) + await self._handle_message(event) + + return {'code': 0, 'message': 'success'} + + except Exception as e: + await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') + return {'error': str(e)}, 400 + + async def run_task(self, host: str, port: int, *args, **kwargs): + """启动 Quart 应用""" + await self.app.run_task(host=host, port=port, *args, **kwargs) + + def on_message(self, msg_type: str): + """注册消息类型处理器""" + + def decorator(func: Callable[[platform_events.Event], None]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + + return decorator + + def on_interaction(self): + """Register a single handler for INTERACTION_CREATE events. + + The handler receives ``(data_dict, interaction_id)`` — the raw + ``d`` payload plus the top-level ``id`` field (the interaction + id, needed for the PUT /interactions/{id} ack and for reuse as + an ``event_id`` on the resumed reply within 30 minutes). + """ + + def decorator(func: Callable[[Dict[str, Any], Optional[str]], Any]): + self._interaction_handler = func + return func + + return decorator + + async def _handle_message(self, event: QQOfficialEvent): + """处理消息事件""" + msg_type = event.t + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + + async def get_message(self, msg: dict) -> Dict[str, Any]: + """获取消息""" + d = msg.get('d', {}) + if not isinstance(d, dict): + return {} + message_data = { + 't': msg.get('t', {}), + 'user_openid': d.get('author', {}).get('user_openid', {}), + 'timestamp': d.get('timestamp', {}), + 'd_author_id': d.get('author', {}).get('id', {}), + 'content': d.get('content', {}), + 'd_id': d.get('id', {}), + 'id': msg.get('id', {}), + 'channel_id': d.get('channel_id', {}), + 'username': d.get('author', {}).get('username', {}), + 'guild_id': d.get('guild_id', {}), + 'member_openid': d.get('author', {}).get('openid', {}), + 'group_openid': d.get('group_openid', {}), + } + attachments = d.get('attachments', []) + image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)] + image_attachments_type = [ + attachment['content_type'] for attachment in attachments if await self.is_image(attachment) + ] + if image_attachments: + message_data['image_attachments'] = image_attachments[0] + message_data['content_type'] = image_attachments_type[0] + else: + message_data['image_attachments'] = None + + return message_data + + async def is_image(self, attachment: dict) -> bool: + """判断是否为图片附件""" + content_type = attachment.get('content_type', '') + return content_type.startswith('image/') + + async def send_private_text_msg( + self, + user_openid: str, + content: str, + msg_id: Optional[str] = None, + event_id: Optional[str] = None, + msg_seq: int = 1, + ): + """Send a c2c text message. + + Either ``msg_id`` (inbound user msg, free passive reply) or + ``event_id`` (e.g. INTERACTION_CREATE id, valid 30 min) is + required. Without either, the call costs the proactive-send quota. + """ + if not await self.check_access_token(): + await self.get_access_token() + + url = self.base_url + '/v2/users/' + user_openid + '/messages' + async with httpx.AsyncClient() as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + data: dict[str, Any] = { + 'content': content, + 'msg_type': 0, + 'msg_seq': msg_seq, + } + if msg_id: + data['msg_id'] = msg_id + if event_id: + data['event_id'] = event_id + response = await client.post(url, headers=headers, json=data) + response_data = response.json() + if response.status_code == 200: + return + else: + await self.logger.error(f'Failed to send private message: {response_data}') + raise ValueError(response) + + async def send_group_text_msg( + self, + group_openid: str, + content: str, + msg_id: Optional[str] = None, + event_id: Optional[str] = None, + msg_seq: int = 1, + ): + """Send a group text message. + + Either ``msg_id`` or ``event_id`` is required (see + :meth:`send_private_text_msg` for the distinction). + """ + if not await self.check_access_token(): + await self.get_access_token() + + url = self.base_url + '/v2/groups/' + group_openid + '/messages' + async with httpx.AsyncClient() as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + data: dict[str, Any] = { + 'content': content, + 'msg_type': 0, + 'msg_seq': msg_seq, + } + if msg_id: + data['msg_id'] = msg_id + if event_id: + data['event_id'] = event_id + response = await client.post(url, headers=headers, json=data) + if response.status_code == 200: + return + else: + await self.logger.error(f'Failed to send group message: {response.json()}') + raise Exception(response.read().decode()) + + async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str): + """发送频道群聊消息""" + if not await self.check_access_token(): + await self.get_access_token() + + url = self.base_url + '/channels/' + channel_id + '/messages' + async with httpx.AsyncClient() as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + params = { + 'content': content, + 'msg_type': 0, + 'msg_id': msg_id, + } + response = await client.post(url, headers=headers, json=params) + if response.status_code == 200: + return True + else: + await self.logger.error(f'Failed to send channel group message: {response.json()}') + raise Exception(response) + + async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str): + """发送频道私聊消息""" + if not await self.check_access_token(): + await self.get_access_token() + + url = self.base_url + '/dms/' + guild_id + '/messages' + async with httpx.AsyncClient() as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + params = { + 'content': content, + 'msg_type': 0, + 'msg_id': msg_id, + } + response = await client.post(url, headers=headers, json=params) + if response.status_code == 200: + return True + else: + await self.logger.error(f'Failed to send channel private message: {response.json()}') + raise Exception(response) + + # ---- 富媒体消息 ---- + + # 媒体文件类型 + MEDIA_TYPE_IMAGE = 1 + MEDIA_TYPE_VIDEO = 2 + MEDIA_TYPE_VOICE = 3 + MEDIA_TYPE_FILE = 4 + + async def upload_media( + self, + target_type: str, + target_id: str, + file_type: int, + file_url: str = None, + file_data: str = None, + file_name: str = None, + ) -> str: + """上传媒体文件,返回 file_info。 + + Args: + target_type: 'c2c' | 'group' + target_id: 用户 openid 或群 openid + file_type: 1=图片, 2=视频, 3=语音, 4=文件 + file_url: 在线 URL(与 file_data 二选一) + file_data: base64 编码的文件数据或 data URL(与 file_url 二选一) + file_name: 文件名(file_type=4 时必填) + """ + if not await self.check_access_token(): + await self.get_access_token() + + if target_type == 'c2c': + url = f'{self.base_url}/v2/users/{target_id}/files' + elif target_type == 'group': + url = f'{self.base_url}/v2/groups/{target_id}/files' + else: + raise ValueError(f'Unsupported target_type: {target_type}') + + body = { + 'file_type': file_type, + 'srv_send_msg': False, + } + if file_url: + body['url'] = file_url + elif file_data: + # 处理 data URL 格式: data:image/png;base64,xxxxx + if file_data.startswith('data:'): + match = re.match(r'^data:[^;]+;base64,(.+)$', file_data, re.DOTALL) + if match: + body['file_data'] = match.group(1) + else: + body['file_data'] = file_data + else: + body['file_data'] = file_data + else: + raise ValueError('file_url or file_data is required') + + if file_type == self.MEDIA_TYPE_FILE and file_name: + body['file_name'] = file_name + + async with httpx.AsyncClient(timeout=120) as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + response = await client.post(url, headers=headers, json=body) + if response.status_code == 200: + data = response.json() + file_info = data.get('file_info', '') + preview = file_info[:80] + '...' if len(file_info) > 80 else file_info + await self.logger.info(f'Upload media success, file_info={preview}') + return file_info + else: + raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}') + + async def _send_media_msg( + self, + target_type: str, + target_id: str, + file_info: str, + msg_id: str = None, + content: str = None, + ): + """发送富媒体消息(msg_type=7)""" + if not await self.check_access_token(): + await self.get_access_token() + + if target_type == 'c2c': + url = f'{self.base_url}/v2/users/{target_id}/messages' + elif target_type == 'group': + url = f'{self.base_url}/v2/groups/{target_id}/messages' + else: + raise ValueError(f'Unsupported target_type: {target_type}') + + self._msg_seq_counter += 1 + msg_seq = self._msg_seq_counter + body = { + 'msg_type': 7, + 'media': {'file_info': file_info}, + 'msg_seq': msg_seq, + } + if content: + body['content'] = content + if msg_id: + body['msg_id'] = msg_id + + async with httpx.AsyncClient(timeout=120) as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}') + response = await client.post(url, headers=headers, json=body) + if response.status_code != 200: + raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}') + + async def send_image_msg( + self, + target_type: str, + target_id: str, + file_url: str = None, + file_data: str = None, + msg_id: str = None, + content: str = None, + ): + """发送图片消息""" + file_info = await self.upload_media( + target_type, + target_id, + self.MEDIA_TYPE_IMAGE, + file_url=file_url, + file_data=file_data, + ) + await self._send_media_msg(target_type, target_id, file_info, msg_id, content) + + async def send_voice_msg( + self, + target_type: str, + target_id: str, + file_url: str = None, + file_data: str = None, + msg_id: str = None, + ): + """发送语音消息""" + file_info = await self.upload_media( + target_type, + target_id, + self.MEDIA_TYPE_VOICE, + file_url=file_url, + file_data=file_data, + ) + await self._send_media_msg(target_type, target_id, file_info, msg_id) + + async def send_file_msg( + self, + target_type: str, + target_id: str, + file_url: str = None, + file_data: str = None, + file_name: str = None, + msg_id: str = None, + ): + """发送文件消息(含视频)""" + file_info = await self.upload_media( + target_type, + target_id, + self.MEDIA_TYPE_FILE, + file_url=file_url, + file_data=file_data, + file_name=file_name, + ) + await self._send_media_msg(target_type, target_id, file_info, msg_id) + + async def send_stream_msg( + self, + user_openid: str, + content: str, + event_id: str, + msg_id: str, + msg_seq: int = 1, + index: int = 0, + stream_msg_id: str = None, + input_state: int = 1, + ): + """发送流式消息(C2C 私聊)。 + + Args: + input_state: 1=生成中, 10=生成结束 + """ + if not await self.check_access_token(): + await self.get_access_token() + + url = f'{self.base_url}/v2/users/{user_openid}/stream_messages' + body = { + 'input_mode': 'replace', + 'input_state': input_state, + 'content_type': 'markdown', + 'content_raw': content, + 'event_id': event_id, + 'msg_id': msg_id, + 'msg_seq': msg_seq, + 'index': index, + } + if stream_msg_id: + body['stream_msg_id'] = stream_msg_id + + async with httpx.AsyncClient(timeout=120) as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + response = await client.post(url, headers=headers, json=body) + if response.status_code != 200: + raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}') + return response.json() + + async def send_markdown_keyboard( + self, + target_type: str, + target_id: str, + markdown_content: str, + keyboard: Optional[dict] = None, + msg_id: Optional[str] = None, + event_id: Optional[str] = None, + msg_seq: int = 1, + ) -> dict: + """Send a ``msg_type=2`` (markdown) message carrying a keyboard. + + The keyboard ride-along is the only documented way to attach + buttons in QQ official; pure keyboard-only messages are not + accepted by the server (markdown content is required). + + Args: + target_type: 'c2c' (single chat), 'group', 'channel' (text + channel — uses POST /channels/{id}/messages instead of v2). + target_id: openid for c2c/group, channel_id for channel. + markdown_content: Plain markdown text shown above the buttons. + keyboard: ``{'content': {'rows': [{'buttons': [...]}]}}`` per + the official spec. Use :func:`build_keyboard_from_form` + to construct from Dify form_data. + msg_id: Inbound user message id; turns this into a passive + reply (preferred — no monthly quota cost). + event_id: Use ``INTERACTION_CREATE`` event id from a prior + button click to keep within the 30-minute passive window + without an inbound msg_id. + msg_seq: De-dup counter when reusing msg_id. + """ + if not await self.check_access_token(): + await self.get_access_token() + + if target_type == 'c2c': + url = f'{self.base_url}/v2/users/{target_id}/messages' + elif target_type == 'group': + url = f'{self.base_url}/v2/groups/{target_id}/messages' + elif target_type == 'channel': + url = f'{self.base_url}/channels/{target_id}/messages' + else: + raise ValueError(f'Unsupported target_type for markdown+keyboard: {target_type}') + + body: dict[str, Any] = { + 'msg_type': 2, + 'markdown': {'content': markdown_content}, + 'msg_seq': msg_seq, + } + if keyboard and keyboard.get('content', {}).get('rows'): + body['keyboard'] = keyboard + if msg_id: + body['msg_id'] = msg_id + if event_id: + body['event_id'] = event_id + + async with httpx.AsyncClient(timeout=30) as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + response = await client.post(url, headers=headers, json=body) + if response.status_code != 200: + await self.logger.error( + f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}' + ) + raise Exception(f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}') + return response.json() + + async def ack_interaction(self, interaction_id: str, code: int = 0) -> None: + """Acknowledge a button-click INTERACTION_CREATE event. + + QQ keeps the client in a loading spinner until this ack is + received. Should be called as soon as the click is parsed, before + any heavier downstream work (the actual workflow resume can run + async). + + Args: + interaction_id: The ``id`` field from the INTERACTION_CREATE event. + code: 0=success, 1=fail, 2=rate-limited, 3=duplicate, 4=no + permission, 5=admin only. Default 0. + """ + if not interaction_id: + return + if not await self.check_access_token(): + await self.get_access_token() + + url = f'{self.base_url}/interactions/{interaction_id}' + async with httpx.AsyncClient(timeout=10) as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + try: + response = await client.put(url, headers=headers, json={'code': code}) + if response.status_code >= 400: + await self.logger.warning( + f'ack_interaction non-success: HTTP {response.status_code} {response.text}' + ) + except Exception as e: + await self.logger.warning(f'ack_interaction error (non-fatal): {e}') + + async def is_token_expired(self): + """检查token是否过期""" + if self.access_token_expiry_time is None: + return True + return time.time() > self.access_token_expiry_time + + async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes: + seed = bot_secret + while len(seed) < target_size: + seed *= 2 + return seed[:target_size].encode('utf-8') + + async def verify(self, validation_payload: dict): + seed = await self.repeat_seed(self.secret) + private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed) + + event_ts = validation_payload.get('event_ts', '') + plain_token = validation_payload.get('plain_token', '') + msg = event_ts + plain_token + + # sign + signature = private_key.sign(msg.encode()).hex() + + response = { + 'plain_token': plain_token, + 'signature': signature, + } + return response + + # ---- WebSocket Gateway ---- + # Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html + + INTENT_GUILDS = 1 << 0 + INTENT_GUILD_MEMBERS = 1 << 1 + INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30 + INTENT_DIRECT_MESSAGE = 1 << 12 + INTENT_GROUP_AND_C2C = 1 << 25 + INTENT_INTERACTION = 1 << 26 + + FULL_INTENTS = ( + INTENT_GUILDS + | INTENT_GUILD_MEMBERS + | INTENT_PUBLIC_GUILD_MESSAGES + | INTENT_DIRECT_MESSAGE + | INTENT_GROUP_AND_C2C + | INTENT_INTERACTION + ) + + async def get_gateway_url(self) -> str: + """获取 WebSocket 网关地址""" + if not await self.check_access_token(): + await self.get_access_token() + + url = f'{self.base_url}/gateway' + async with httpx.AsyncClient() as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + } + response = await client.get(url, headers=headers) + if response.status_code == 200: + data = response.json() + ws_url = data.get('url', '') + if not ws_url: + raise Exception('Gateway URL is empty') + return ws_url + else: + raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}') + + async def _background_token_refresh(self): + """在 token 到期前主动刷新""" + try: + while True: + if self.access_token_expiry_time: + remain = self.access_token_expiry_time - time.time() + if remain > 120: + await asyncio.sleep(remain - 60) + continue + self.access_token = '' + self.access_token_expiry_time = None + if await self.check_access_token(): + await asyncio.sleep(60) + else: + await self.get_access_token() + await asyncio.sleep(60) + except asyncio.CancelledError: + pass + + async def connect_gateway( + self, + on_event: Callable[[str, dict], Any], + on_ready: Optional[Callable[[], Any]] = None, + on_error: Optional[Callable[[Exception], Any]] = None, + ): + """WebSocket 网关连接,含重连逻辑。持续重连直到达到最大次数或被取消。 + + Args: + on_event: 收到 op=0 Dispatch 事件时的回调,参数为 (event_type, event_data) + on_ready: 连接就绪 (收到 READY) 时的回调 + on_error: 发生错误时的回调 + """ + import websockets + + session_id = '' + last_seq = 0 + reconnect_attempts = 0 + max_reconnect_attempts = 100 + backoff_delays = [1, 2, 5, 10, 30, 60] + rate_limit_delay = 60 + + # Cancel previous token refresh task if any + if self._token_refresh_task and not self._token_refresh_task.done(): + self._token_refresh_task.cancel() + try: + await self._token_refresh_task + except asyncio.CancelledError: + pass + self._token_refresh_task = None + + while reconnect_attempts <= max_reconnect_attempts: + heartbeat_interval = 45000 + should_refresh_token = False + ws = None + heartbeat_task = None + + # Refresh token if needed + if should_refresh_token: + self.access_token = '' + self.access_token_expiry_time = None + + try: + ws_url = await self.get_gateway_url() + await self.logger.info(f'Gateway URL obtained: {ws_url[:60]}...') + except Exception as e: + error_msg = str(e) + await self.logger.error(f'Failed to get gateway URL: {e}') + reconnect_attempts += 1 + if '100017' in error_msg or '频率' in error_msg or 'Too many' in error_msg: + delay = rate_limit_delay + else: + delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)] + await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})') + await asyncio.sleep(delay) + continue + + try: + await self.logger.info('Connecting to WebSocket gateway...') + ws = await websockets.connect(ws_url) + await self.logger.info('WebSocket connected') + except Exception as e: + await self.logger.error(f'WebSocket connection failed: {e}') + reconnect_attempts += 1 + delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)] + await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})') + await asyncio.sleep(delay) + continue + + try: + async for raw_msg in ws: + try: + payload = json.loads(raw_msg) + except json.JSONDecodeError: + await self.logger.error(f'Failed to parse message: {raw_msg}') + continue + + op = payload.get('op') + d = payload.get('d', {}) + s = payload.get('s') + t = payload.get('t') + # Top-level event id, distinct from `d.id`. Per QQ + # spec this is the only value accepted as ``event_id`` + # in subsequent passive-reply send-message calls + # (``d.id`` for INTERACTION_CREATE is the interaction + # id, used solely for PUT /interactions/{id} ack). + ws_event_id = payload.get('id') + + if not isinstance(d, dict): + d = {} + + if op == 10: # Hello + heartbeat_interval = d.get('heartbeat_interval', 45000) + await self.logger.info(f'Received Hello, heartbeat_interval={heartbeat_interval}ms') + + # Send Identify or Resume + if session_id and last_seq > 0: + resume_payload = { + 'op': 6, + 'd': { + 'token': f'QQBot {self.access_token}', + 'session_id': session_id, + 'seq': last_seq, + }, + } + await ws.send(json.dumps(resume_payload)) + await self.logger.info(f'Sent Resume, session_id={session_id}, seq={last_seq}') + else: + identify_payload = { + 'op': 2, + 'd': { + 'token': f'QQBot {self.access_token}', + 'intents': self.FULL_INTENTS, + 'shard': [0, 1], + }, + } + await ws.send(json.dumps(identify_payload)) + await self.logger.info(f'Sent Identify, intents={self.FULL_INTENTS}') + + # Start heartbeat + async def _heartbeat_loop(conn, interval_ms): + interval_sec = interval_ms / 1000.0 + try: + while True: + await asyncio.sleep(interval_sec) + try: + hb_payload = {'op': 1, 'd': last_seq} + await conn.send(json.dumps(hb_payload)) + except Exception: + break + except asyncio.CancelledError: + pass + + heartbeat_task = asyncio.create_task(_heartbeat_loop(ws, heartbeat_interval)) + + elif op == 0: # Dispatch + if s is not None: + last_seq = s + + if t == 'READY': + session_id = d.get('session_id', '') + reconnect_attempts = 0 + await self.logger.info(f'READY, session_id={session_id}') + if on_ready: + try: + result = on_ready() + if asyncio.iscoroutine(result): + await result + except Exception: + pass + # Track token refresh task to avoid leaks + if self._token_refresh_task and not self._token_refresh_task.done(): + self._token_refresh_task.cancel() + try: + await self._token_refresh_task + except asyncio.CancelledError: + pass + self._token_refresh_task = asyncio.create_task(self._background_token_refresh()) + + elif t == 'RESUMED': + reconnect_attempts = 0 + await self.logger.info('RESUMED') + + else: + await self.logger.debug(f'Received event: {t}, seq={s}') + # INTERACTION_CREATE bypasses the regular + # on_event dispatcher so the adapter sees the + # top-level ws_event_id (needed as event_id + # for the resumed reply) — same shape as the + # webhook handler. + if t == 'INTERACTION_CREATE': + if self._interaction_handler: + try: + result = self._interaction_handler(d, ws_event_id) + if asyncio.iscoroutine(result): + await result + except Exception: + await self.logger.error( + f'Error in interaction handler (ws): {traceback.format_exc()}' + ) + elif on_event: + try: + result = on_event(t, d) + if asyncio.iscoroutine(result): + await result + except Exception: + await self.logger.error(f'Error handling event {t}: {traceback.format_exc()}') + + elif op == 11: # Heartbeat ACK + pass + + elif op == 7: # Reconnect + await self.logger.info('Received Reconnect directive') + break + + elif op == 9: # Invalid Session + can_resume = d.get('can_resume', False) + await self.logger.warning(f'Invalid Session, can_resume={can_resume}') + if not can_resume: + session_id = '' + last_seq = 0 + should_refresh_token = True + break + + # Connection closed normally (end of async for) + try: + close_code = ws.close_code + close_reason = ws.close_reason or '' + except Exception: + close_code = None + close_reason = '' + await self.logger.info(f'Connection closed, code={close_code}, reason={close_reason}') + + if close_code == 4004: + should_refresh_token = True + elif close_code in (4006, 4007, 4009): + session_id = '' + last_seq = 0 + should_refresh_token = True + elif close_code == 4008: + reconnect_attempts += 1 + delay = rate_limit_delay + await self.logger.info( + f'Rate limited, waiting {delay}s before reconnect (attempt {reconnect_attempts})' + ) + await asyncio.sleep(delay) + continue + elif close_code in (4914, 4915): + err = Exception(f'Bot disconnected/banned (close_code={close_code})') + if on_error: + await self._safe_callback(on_error, err) + return + elif close_code in (4900, 4901, 4902, 4903, 4904, 4905, 4906, 4907, 4908, 4909, 4910, 4911, 4912, 4913): + session_id = '' + last_seq = 0 + + if close_code == 1000: + return + + except asyncio.CancelledError: + raise + except Exception: + await self.logger.error(f'Unexpected error in WebSocket loop: {traceback.format_exc()}') + finally: + if heartbeat_task: + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass + if ws: + try: + await ws.close() + except Exception: + pass + + # If we reach here, we need to reconnect + reconnect_attempts += 1 + if reconnect_attempts > max_reconnect_attempts: + await self.logger.error(f'Max reconnect attempts ({max_reconnect_attempts}) reached, stopping') + if on_error: + await self._safe_callback(on_error, Exception('Max reconnect attempts reached')) + return + delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)] + await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})') + await asyncio.sleep(delay) + + async def _safe_callback(self, callback, *args): + """Safely invoke a callback, handling both sync and async functions.""" + try: + result = callback(*args) + if asyncio.iscoroutine(result): + await result + except Exception: + pass + + async def connect_gateway_loop( + self, + on_event: Callable[[str, dict], Any], + on_ready: Optional[Callable[[], Any]] = None, + on_error: Optional[Callable[[Exception], Any]] = None, + ): + """持续重连的网关循环。""" + await self.connect_gateway(on_event, on_ready, on_error) diff --git a/src/langbot/libs/qq_official_api/qqofficialevent.py b/src/langbot/libs/qq_official_api/qqofficialevent.py new file mode 100644 index 0000000..7c29b9d --- /dev/null +++ b/src/langbot/libs/qq_official_api/qqofficialevent.py @@ -0,0 +1,112 @@ +from typing import Dict, Any, Optional + + +class QQOfficialEvent(dict): + @staticmethod + def from_payload(payload: Dict[str, Any]) -> Optional['QQOfficialEvent']: + try: + event = QQOfficialEvent(payload) + return event + except KeyError: + return None + + @property + def t(self) -> str: + """ + 事件类型 + """ + return self.get('t', '') + + @property + def user_openid(self) -> str: + """ + 用户openid + """ + return self.get('user_openid', {}) + + @property + def timestamp(self) -> str: + """ + 时间戳 + """ + return self.get('timestamp', {}) + + @property + def d_author_id(self) -> str: + """ + 作者id + """ + return self.get('id', {}) + + @property + def content(self) -> str: + """ + 内容 + """ + return self.get('content', '') + + @property + def d_id(self) -> str: + """ + d_id + """ + return self.get('d_id', {}) + + @property + def id(self) -> str: + """ + 消息id,msg_id + """ + return self.get('id', {}) + + @property + def channel_id(self) -> str: + """ + 频道id + """ + return self.get('channel_id', {}) + + @property + def username(self) -> str: + """ + 用户名 + """ + return self.get('username', {}) + + @property + def guild_id(self) -> str: + """ + 频道id + """ + return self.get('guild_id', {}) + + @property + def member_openid(self) -> str: + """ + 成员openid + """ + return self.get('openid', {}) + + @property + def attachments(self) -> str: + """ + 附件url + """ + url = self.get('image_attachments', '') + if url and not url.startswith('https://'): + url = 'https://' + url + return url + + @property + def group_openid(self) -> str: + """ + 群组id + """ + return self.get('group_openid', {}) + + @property + def content_type(self) -> str: + """ + 文件类型 + """ + return self.get('content_type', '') diff --git a/src/langbot/libs/slack_api/__init__.py b/src/langbot/libs/slack_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/libs/slack_api/api.py b/src/langbot/libs/slack_api/api.py new file mode 100644 index 0000000..6f869b9 --- /dev/null +++ b/src/langbot/libs/slack_api/api.py @@ -0,0 +1,127 @@ +import json +import traceback +from quart import Quart, jsonify, request +from slack_sdk.web.async_client import AsyncWebClient +from .slackevent import SlackEvent +from typing import Callable +import langbot_plugin.api.entities.builtin.platform.events as platform_events + + +class SlackClient: + def __init__(self, bot_token: str, signing_secret: str, logger: None, unified_mode: bool = False): + self.bot_token = bot_token + self.signing_secret = signing_secret + self.unified_mode = unified_mode + self.app = Quart(__name__) + self.client = AsyncWebClient(self.bot_token) + + # 只有在非统一模式下才注册独立路由 + if not self.unified_mode: + self.app.add_url_rule( + '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'] + ) + + self._message_handlers = { + 'example': [], + } + self.bot_user_id = None # 避免机器人回复自己的消息 + self.logger = logger + + async def handle_callback_request(self): + """处理回调请求(独立端口模式,使用全局 request)""" + return await self._handle_callback_internal(request) + + async def handle_unified_webhook(self, req): + """处理回调请求(统一 webhook 模式,显式传递 request)。 + + Args: + req: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """处理回调请求的内部实现。 + + Args: + req: Quart Request 对象 + """ + try: + body = await req.get_data() + data = json.loads(body) + if 'type' in data: + if data['type'] == 'url_verification': + return data['challenge'] + + bot_user_id = data.get('event', {}).get('bot_id', '') + + if self.bot_user_id and bot_user_id == self.bot_user_id: + return jsonify({'status': 'ok'}) + + # 处理私信 + if data and data.get('event', {}).get('channel_type') in ['im']: + event = SlackEvent.from_payload(data) + await self._handle_message(event) + return jsonify({'status': 'ok'}) + + # 处理群聊 + if data.get('event', {}).get('type') == 'app_mention': + data.setdefault('event', {})['channel_type'] = 'channel' + event = SlackEvent.from_payload(data) + await self._handle_message(event) + return jsonify({'status': 'ok'}) + + return jsonify({'status': 'ok'}) + + except Exception as e: + await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') + raise (e) + + async def _handle_message(self, event: SlackEvent): + """ + 处理消息事件。 + """ + msg_type = event.type + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + + def on_message(self, msg_type: str): + """注册消息类型处理器""" + + def decorator(func: Callable[[platform_events.Event], None]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + + return decorator + + async def send_message_to_channel(self, text: str, channel_id: str): + try: + response = await self.client.chat_postMessage(channel=channel_id, text=text) + if self.bot_user_id is None and response.get('ok'): + self.bot_user_id = response['message']['bot_id'] + return + except Exception as e: + await self.logger.error(f'Error in send_message: {e}') + raise e + + async def send_message_to_one(self, text: str, user_id: str): + try: + response = await self.client.chat_postMessage(channel='@' + user_id, text=text) + if self.bot_user_id is None and response.get('ok'): + self.bot_user_id = response['message']['bot_id'] + + return + except Exception as e: + await self.logger.error(f'Error in send_message: {traceback.format_exc()}') + raise e + + async def run_task(self, host: str, port: int, *args, **kwargs): + """ + 启动 Quart 应用。 + """ + await self.app.run_task(host=host, port=port, *args, **kwargs) diff --git a/src/langbot/libs/slack_api/slackevent.py b/src/langbot/libs/slack_api/slackevent.py new file mode 100644 index 0000000..7717781 --- /dev/null +++ b/src/langbot/libs/slack_api/slackevent.py @@ -0,0 +1,87 @@ +from typing import Dict, Any, Optional + + +class SlackEvent(dict): + @staticmethod + def from_payload(payload: Dict[str, Any]) -> Optional['SlackEvent']: + try: + event = SlackEvent(payload) + return event + except KeyError: + return None + + @property + def text(self) -> str: + if self.get('event', {}).get('channel_type') == 'im': + blocks = self.get('event', {}).get('blocks', []) + if not blocks: + return '' + + elements = blocks[0].get('elements', []) + if not elements: + return '' + + elements = elements[0].get('elements', []) + text = '' + + for el in elements: + if el.get('type') == 'text': + text += el.get('text', '') + elif el.get('type') == 'link': + text += el.get('url', '') + + return text + + if self.get('event', {}).get('channel_type') == 'channel': + message_text = '' + for block in self.get('event', {}).get('blocks', []): + if block.get('type') == 'rich_text': + for element in block.get('elements', []): + if element.get('type') == 'rich_text_section': + parts = [] + for el in element.get('elements', []): + if el.get('type') == 'text': + parts.append(el['text']) + elif el.get('type') == 'link': + parts.append(el['url']) + message_text = ''.join(parts) + + return message_text + + @property + def user_id(self) -> Optional[str]: + return self.get('event', {}).get('user', '') + + @property + def channel_id(self) -> Optional[str]: + return self.get('event', {}).get('channel', '') + + @property + def type(self) -> str: + """message对应私聊,app_mention对应频道at""" + return self.get('event', {}).get('channel_type', '') + + @property + def message_id(self) -> str: + return self.get('event_id', '') + + @property + def pic_url(self) -> str: + """提取 Slack 事件中的图片 URL""" + files = self.get('event', {}).get('files', []) + if files: + return files[0].get('url_private', '') + return None + + @property + def sender_name(self) -> str: + return self.get('event', {}).get('user', '') + + def __getattr__(self, key: str) -> Optional[Any]: + return self.get(key) + + def __setattr__(self, key: str, value: Any) -> None: + self[key] = value + + def __repr__(self) -> str: + return f'' diff --git a/src/langbot/libs/wechatpad_api/LICENSE b/src/langbot/libs/wechatpad_api/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/src/langbot/libs/wechatpad_api/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/langbot/libs/wechatpad_api/README.md b/src/langbot/libs/wechatpad_api/README.md new file mode 100644 index 0000000..5720c2c --- /dev/null +++ b/src/langbot/libs/wechatpad_api/README.md @@ -0,0 +1,38 @@ +# wechatpad-python + + +## 此项目时准备对接wechatpadpro 的pythonsdk + +## 未完工接口 + +* 关于好友的接口 +* 关于群管理的接口 +* 关于下载的接口 +* 关于用户的部分接口 +* 关于消息的部分接口 +* 关于支付的 +* 关于朋友圈的 +* 关于标签的 +* 关于收藏的 + +* 暂时只写了一部分接口 + + +## 已完工接口 + +1. 获取普通token +2. 登录二维码(只是返回数据,暂时还未打印二维码) +3. 获取登录状态 +4. 唤醒登录 +5. 退出登录 +6. 获取用户信息 +7. 获取用户二维码 +8. 上传用户头像 +9. 获取设备信息 +10. 发送文本消息 +11. 发送图片消息 +12. 发送语音消息 +13. 发送app消息 +14. 发送emoji消息 +15. 发送名片消息 +16. 撤回消息 diff --git a/src/langbot/libs/wechatpad_api/__init__.py b/src/langbot/libs/wechatpad_api/__init__.py new file mode 100644 index 0000000..9ac533f --- /dev/null +++ b/src/langbot/libs/wechatpad_api/__init__.py @@ -0,0 +1 @@ +from .client import WeChatPadClient as WeChatPadClient diff --git a/src/langbot/libs/wechatpad_api/api/__init__.py b/src/langbot/libs/wechatpad_api/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/libs/wechatpad_api/api/chatroom.py b/src/langbot/libs/wechatpad_api/api/chatroom.py new file mode 100644 index 0000000..63360a2 --- /dev/null +++ b/src/langbot/libs/wechatpad_api/api/chatroom.py @@ -0,0 +1,12 @@ +from langbot.libs.wechatpad_api.util.http_util import post_json + + +class ChatRoomApi: + def __init__(self, base_url, token): + self.base_url = base_url + self.token = token + + def get_chatroom_member_detail(self, chatroom_name): + params = {'ChatRoomName': chatroom_name} + url = self.base_url + '/group/GetChatroomMemberDetail' + return post_json(url, token=self.token, data=params) diff --git a/src/langbot/libs/wechatpad_api/api/downloadpai.py b/src/langbot/libs/wechatpad_api/api/downloadpai.py new file mode 100644 index 0000000..3fbdb62 --- /dev/null +++ b/src/langbot/libs/wechatpad_api/api/downloadpai.py @@ -0,0 +1,30 @@ +from langbot.libs.wechatpad_api.util.http_util import post_json +import httpx +import base64 + + +class DownloadApi: + def __init__(self, base_url, token): + self.base_url = base_url + self.token = token + + def send_download(self, aeskey, file_type, file_url): + json_data = {'AesKey': aeskey, 'FileType': file_type, 'FileURL': file_url} + url = self.base_url + '/message/SendCdnDownload' + return post_json(url, token=self.token, data=json_data) + + def get_msg_voice(self, buf_id, length, new_msgid): + json_data = {'Bufid': buf_id, 'Length': length, 'NewMsgId': new_msgid, 'ToUserName': ''} + url = self.base_url + '/message/GetMsgVoice' + return post_json(url, token=self.token, data=json_data) + + async def download_url_to_base64(self, download_url): + async with httpx.AsyncClient() as client: + response = await client.get(download_url) + + if response.status_code == 200: + file_bytes = response.content + base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式 + return base64_str + else: + raise Exception('获取文件失败') diff --git a/src/langbot/libs/wechatpad_api/api/friend.py b/src/langbot/libs/wechatpad_api/api/friend.py new file mode 100644 index 0000000..a7a448a --- /dev/null +++ b/src/langbot/libs/wechatpad_api/api/friend.py @@ -0,0 +1,6 @@ +class FriendApi: + """联系人API类,处理所有与联系人相关的操作""" + + def __init__(self, base_url: str, token: str): + self.base_url = base_url + self.token = token diff --git a/src/langbot/libs/wechatpad_api/api/login.py b/src/langbot/libs/wechatpad_api/api/login.py new file mode 100644 index 0000000..23e89b8 --- /dev/null +++ b/src/langbot/libs/wechatpad_api/api/login.py @@ -0,0 +1,82 @@ +from langbot.libs.wechatpad_api.util.http_util import post_json, get_json + + +class LoginApi: + def __init__(self, base_url: str, token: str = None, admin_key: str = None): + """ + + Args: + base_url: 原始路径 + token: token + admin_key: 管理员key + """ + self.base_url = base_url + self.token = token + # self.admin_key = admin_key + + def get_token(self, admin_key, day: int = 365): + # 获取普通token + url = f'{self.base_url}/admin/GenAuthKey1' + json_data = {'Count': 1, 'Days': day} + return post_json(base_url=url, token=admin_key, data=json_data) + + def get_login_qr(self, Proxy: str = ''): + """ + + Args: + Proxy:异地使用时代理 + + Returns:json数据 + + """ + """ + + { + "Code": 200, + "Data": { + "Key": "3141312", + "QrCodeUrl": "https://1231x/g6bMlv2dX8zwNbqE6-Zs", + "Txt": "建议返回data=之后内容自定义生成二维码", + "baseResp": { + "ret": 0, + "errMsg": {} + } + }, + "Text": "" +} + + """ + # 获取登录二维码 + url = f'{self.base_url}/login/GetLoginQrCodeNew' + check = False + if Proxy != '': + check = True + json_data = {'Check': check, 'Proxy': Proxy} + return post_json(base_url=url, token=self.token, data=json_data) + + def get_login_status(self): + # 获取登录状态 + url = f'{self.base_url}/login/GetLoginStatus' + return get_json(base_url=url, token=self.token) + + def logout(self): + # 退出登录 + url = f'{self.base_url}/login/LogOut' + return post_json(base_url=url, token=self.token) + + def wake_up_login(self, Proxy: str = ''): + # 唤醒登录 + url = f'{self.base_url}/login/WakeUpLogin' + check = False + if Proxy != '': + check = True + json_data = {'Check': check, 'Proxy': ''} + + return post_json(base_url=url, token=self.token, data=json_data) + + def login(self, admin_key): + login_status = self.get_login_status() + if login_status['Code'] == 300 and login_status['Text'] == '你已退出微信': + print('token已经失效,重新获取') + token_data = self.get_token(admin_key) + self.token = token_data['Data'][0] diff --git a/src/langbot/libs/wechatpad_api/api/message.py b/src/langbot/libs/wechatpad_api/api/message.py new file mode 100644 index 0000000..52c7bdb --- /dev/null +++ b/src/langbot/libs/wechatpad_api/api/message.py @@ -0,0 +1,80 @@ +from langbot.libs.wechatpad_api.util.http_util import post_json + + +class MessageApi: + def __init__(self, base_url, token): + self.base_url = base_url + self.token = token + + def post_text(self, to_wxid, content, ats: list = []): + """ + + Args: + app_id: 微信id + to_wxid: 发送方的微信id + content: 内容 + ats: at + + Returns: + + """ + url = self.base_url + '/message/SendTextMessage' + """发送文字消息""" + json_data = { + 'MsgItem': [ + {'AtWxIDList': ats, 'ImageContent': '', 'MsgType': 0, 'TextContent': content, 'ToUserName': to_wxid} + ] + } + return post_json(base_url=url, token=self.token, data=json_data) + + def post_image(self, to_wxid, img_url, ats: list = []): + """发送图片消息""" + # 这里好像可以尝试发送多个暂时未测试 + json_data = { + 'MsgItem': [ + {'AtWxIDList': ats, 'ImageContent': img_url, 'MsgType': 0, 'TextContent': '', 'ToUserName': to_wxid} + ] + } + url = self.base_url + '/message/SendImageMessage' + return post_json(base_url=url, token=self.token, data=json_data) + + def post_voice(self, to_wxid, voice_data, voice_forma, voice_duration): + """发送语音消息""" + json_data = { + 'ToUserName': to_wxid, + 'VoiceData': voice_data, + 'VoiceFormat': voice_forma, + 'VoiceSecond': voice_duration, + } + url = self.base_url + '/message/SendVoice' + return post_json(base_url=url, token=self.token, data=json_data) + + def post_name_card(self, alias, to_wxid, nick_name, name_card_wxid, flag): + """发送名片消息""" + param = { + 'CardAlias': alias, + 'CardFlag': flag, + 'CardNickName': nick_name, + 'CardWxId': name_card_wxid, + 'ToUserName': to_wxid, + } + url = f'{self.base_url}/message/ShareCardMessage' + return post_json(base_url=url, token=self.token, data=param) + + def post_emoji(self, to_wxid, emoji_md5, emoji_size: int = 0): + """发送emoji消息""" + json_data = {'EmojiList': [{'EmojiMd5': emoji_md5, 'EmojiSize': emoji_size, 'ToUserName': to_wxid}]} + url = f'{self.base_url}/message/SendEmojiMessage' + return post_json(base_url=url, token=self.token, data=json_data) + + def post_app_msg(self, to_wxid, xml_data, contenttype: int = 0): + """发送appmsg消息""" + json_data = {'AppList': [{'ContentType': contenttype, 'ContentXML': xml_data, 'ToUserName': to_wxid}]} + url = f'{self.base_url}/message/SendAppMessage' + return post_json(base_url=url, token=self.token, data=json_data) + + def revoke_msg(self, to_wxid, msg_id, new_msg_id, create_time): + """撤回消息""" + param = {'ClientMsgId': msg_id, 'CreateTime': create_time, 'NewMsgId': new_msg_id, 'ToUserName': to_wxid} + url = f'{self.base_url}/message/RevokeMsg' + return post_json(base_url=url, token=self.token, data=param) diff --git a/src/langbot/libs/wechatpad_api/api/user.py b/src/langbot/libs/wechatpad_api/api/user.py new file mode 100644 index 0000000..0725b9d --- /dev/null +++ b/src/langbot/libs/wechatpad_api/api/user.py @@ -0,0 +1,30 @@ +from langbot.libs.wechatpad_api.util.http_util import post_json, async_request, get_json + + +class UserApi: + def __init__(self, base_url, token): + self.base_url = base_url + self.token = token + + def get_profile(self): + """获取个人资料""" + url = f'{self.base_url}/user/GetProfile' + + return get_json(base_url=url, token=self.token) + + def get_qr_code(self, recover: bool = True, style: int = 8): + """获取自己的二维码""" + param = {'Recover': recover, 'Style': style} + url = f'{self.base_url}/user/GetMyQRCode' + return post_json(base_url=url, token=self.token, data=param) + + def get_safety_info(self): + """获取设备记录""" + url = f'{self.base_url}/equipment/GetSafetyInfo' + return post_json(base_url=url, token=self.token) + + async def update_head_img(self, head_img_base64): + """修改头像""" + param = {'Base64': head_img_base64} + url = f'{self.base_url}/user/UploadHeadImage' + return await async_request(base_url=url, token_key=self.token, json=param) diff --git a/src/langbot/libs/wechatpad_api/client.py b/src/langbot/libs/wechatpad_api/client.py new file mode 100644 index 0000000..bb9b2f5 --- /dev/null +++ b/src/langbot/libs/wechatpad_api/client.py @@ -0,0 +1,92 @@ +from langbot.libs.wechatpad_api.api.login import LoginApi +from langbot.libs.wechatpad_api.api.friend import FriendApi +from langbot.libs.wechatpad_api.api.message import MessageApi +from langbot.libs.wechatpad_api.api.user import UserApi +from langbot.libs.wechatpad_api.api.downloadpai import DownloadApi +from langbot.libs.wechatpad_api.api.chatroom import ChatRoomApi + + +class WeChatPadClient: + def __init__(self, base_url, token, logger=None): + self._login_api = LoginApi(base_url, token) + self._friend_api = FriendApi(base_url, token) + self._message_api = MessageApi(base_url, token) + self._user_api = UserApi(base_url, token) + self._download_api = DownloadApi(base_url, token) + self._chatroom_api = ChatRoomApi(base_url, token) + self.logger = logger + + def get_token(self, admin_key, day: int): + """获取token""" + return self._login_api.get_token(admin_key, day) + + def get_login_qr(self, Proxy: str = ''): + """登录二维码""" + return self._login_api.get_login_qr(Proxy=Proxy) + + def awaken_login(self, Proxy: str = ''): + """唤醒登录""" + return self._login_api.wake_up_login(Proxy=Proxy) + + def log_out(self): + """退出登录""" + return self._login_api.logout() + + def get_login_status(self): + """获取登录状态""" + return self._login_api.get_login_status() + + def send_text_message(self, to_wxid, message, ats: list = []): + """发送文本消息""" + return self._message_api.post_text(to_wxid, message, ats) + + def send_image_message(self, to_wxid, img_url, ats: list = []): + """发送图片消息""" + return self._message_api.post_image(to_wxid, img_url, ats) + + def send_voice_message(self, to_wxid, voice_data, voice_forma, voice_duration): + """发送音频消息""" + return self._message_api.post_voice(to_wxid, voice_data, voice_forma, voice_duration) + + def send_app_message(self, to_wxid, app_message, type): + """发送app消息""" + return self._message_api.post_app_msg(to_wxid, app_message, type) + + def send_emoji_message(self, to_wxid, emoji_md5, emoji_size): + """发送emoji消息""" + return self._message_api.post_emoji(to_wxid, emoji_md5, emoji_size) + + def revoke_msg(self, to_wxid, msg_id, new_msg_id, create_time): + """撤回消息""" + return self._message_api.revoke_msg(to_wxid, msg_id, new_msg_id, create_time) + + def get_profile(self): + """获取用户信息""" + return self._user_api.get_profile() + + def get_qr_code(self, recover: bool = True, style: int = 8): + """获取用户二维码""" + return self._user_api.get_qr_code(recover=recover, style=style) + + def get_safety_info(self): + """获取设备信息""" + return self._user_api.get_safety_info() + + def update_head_img(self, head_img_base64): + """上传用户头像""" + return self._user_api.update_head_img(head_img_base64) + + def cdn_download(self, aeskey, file_type, file_url): + """cdn下载""" + return self._download_api.send_download(aeskey, file_type, file_url) + + def get_msg_voice(self, buf_id, length, msgid): + """下载语音""" + return self._download_api.get_msg_voice(buf_id, length, msgid) + + async def download_base64(self, url): + return await self._download_api.download_url_to_base64(download_url=url) + + def get_chatroom_member_detail(self, chatroom_name): + """查看群成员详情""" + return self._chatroom_api.get_chatroom_member_detail(chatroom_name) diff --git a/src/langbot/libs/wechatpad_api/util/__init__.py b/src/langbot/libs/wechatpad_api/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/libs/wechatpad_api/util/http_util.py b/src/langbot/libs/wechatpad_api/util/http_util.py new file mode 100644 index 0000000..7390f43 --- /dev/null +++ b/src/langbot/libs/wechatpad_api/util/http_util.py @@ -0,0 +1,78 @@ +import requests +from langbot.pkg.utils import httpclient + + +def post_json(base_url, token, data=None): + headers = {'Content-Type': 'application/json'} + + url = base_url + f'?key={token}' + + try: + response = requests.post(url, json=data, headers=headers, timeout=60) + response.raise_for_status() + result = response.json() + + if result: + return result + else: + raise RuntimeError(response.text) + except Exception as e: + print(f'http请求失败, url={url}, exception={e}') + raise RuntimeError(str(e)) + + +def get_json(base_url, token): + headers = {'Content-Type': 'application/json'} + + url = base_url + f'?key={token}' + + try: + response = requests.get(url, headers=headers, timeout=60) + response.raise_for_status() + result = response.json() + + if result: + return result + else: + raise RuntimeError(response.text) + except Exception as e: + print(f'http请求失败, url={url}, exception={e}') + raise RuntimeError(str(e)) + + +async def async_request( + base_url: str, + token_key: str, + method: str = 'POST', + params: dict = None, + # headers: dict = None, + data: dict = None, + json: dict = None, +): + """ + 通用异步请求函数 + + :param base_url: 请求URL + :param token_key: 请求token + :param method: HTTP方法 (GET, POST, PUT, DELETE等) + :param params: URL查询参数 + # :param headers: 请求头 + :param data: 表单数据 + :param json: JSON数据 + :return: 响应文本 + """ + headers = {'Content-Type': 'application/json'} + url = f'{base_url}?key={token_key}' + session = httpclient.get_session() + async with session.request( + method=method, url=url, params=params, headers=headers, data=data, json=json + ) as response: + response.raise_for_status() # 如果状态码不是200,抛出异常 + result = await response.json() + # print(result) + return result + # if result.get('Code') == 200: + # + # return await result + # else: + # raise RuntimeError("请求失败",response.text) diff --git a/src/langbot/libs/wechatpad_api/util/terminal_printer.py b/src/langbot/libs/wechatpad_api/util/terminal_printer.py new file mode 100644 index 0000000..19a35ff --- /dev/null +++ b/src/langbot/libs/wechatpad_api/util/terminal_printer.py @@ -0,0 +1,34 @@ +import qrcode + + +def print_green(text): + print(f'\033[32m{text}\033[0m') + + +def print_yellow(text): + print(f'\033[33m{text}\033[0m') + + +def print_red(text): + print(f'\033[31m{text}\033[0m') + + +def make_and_print_qr(url): + """生成并打印二维码 + + Args: + url: 需要生成二维码的URL字符串 + + Returns: + None + + 功能: + 1. 在终端打印二维码的ASCII图形 + 2. 同时提供在线二维码生成链接作为备选 + """ + print_green('请扫描下方二维码登录') + qr = qrcode.QRCode() + qr.add_data(url) + qr.make() + qr.print_ascii(invert=True) + print_green(f'也可以访问下方链接获取二维码:\nhttps://api.qrserver.com/v1/create-qr-code/?data={url}') diff --git a/src/langbot/libs/wecom_ai_bot_api/WXBizMsgCrypt3.py b/src/langbot/libs/wecom_ai_bot_api/WXBizMsgCrypt3.py new file mode 100644 index 0000000..bd8250d --- /dev/null +++ b/src/langbot/libs/wecom_ai_bot_api/WXBizMsgCrypt3.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python +# -*- encoding:utf-8 -*- + +"""对企业微信发送给企业后台的消息加解密示例代码. +@copyright: Copyright (c) 1998-2014 Tencent Inc. + +""" + +# ------------------------------------------------------------------------ +import logging +import base64 +import random +import hashlib +import time +import struct +from Crypto.Cipher import AES +import xml.etree.cElementTree as ET +import socket +from langbot.libs.wecom_ai_bot_api import ierror + + +""" +Crypto.Cipher包已不再维护,开发者可以通过以下命令下载安装最新版的加解密工具包 + pip install pycryptodome +""" + + +class FormatException(Exception): + pass + + +def throw_exception(message, exception_class=FormatException): + """my define raise exception function""" + raise exception_class(message) + + +class SHA1: + """计算企业微信的消息签名接口""" + + def getSHA1(self, token, timestamp, nonce, encrypt): + """用SHA1算法生成安全签名 + @param token: 票据 + @param timestamp: 时间戳 + @param encrypt: 密文 + @param nonce: 随机字符串 + @return: 安全签名 + """ + try: + sortlist = [token, timestamp, nonce, encrypt] + sortlist.sort() + sha = hashlib.sha1() + sha.update(''.join(sortlist).encode()) + return ierror.WXBizMsgCrypt_OK, sha.hexdigest() + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_ComputeSignature_Error, None + + +class XMLParse: + """提供提取消息格式中的密文及生成回复消息格式的接口""" + + # xml消息模板 + AES_TEXT_RESPONSE_TEMPLATE = """ + + +%(timestamp)s + +""" + + def extract(self, xmltext): + """提取出xml数据包中的加密消息 + @param xmltext: 待提取的xml字符串 + @return: 提取出的加密消息字符串 + """ + try: + xml_tree = ET.fromstring(xmltext) + encrypt = xml_tree.find('Encrypt') + return ierror.WXBizMsgCrypt_OK, encrypt.text + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_ParseXml_Error, None + + def generate(self, encrypt, signature, timestamp, nonce): + """生成xml消息 + @param encrypt: 加密后的消息密文 + @param signature: 安全签名 + @param timestamp: 时间戳 + @param nonce: 随机字符串 + @return: 生成的xml字符串 + """ + resp_dict = { + 'msg_encrypt': encrypt, + 'msg_signaturet': signature, + 'timestamp': timestamp, + 'nonce': nonce, + } + resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict + return resp_xml + + +class PKCS7Encoder: + """提供基于PKCS7算法的加解密接口""" + + block_size = 32 + + def encode(self, text): + """对需要加密的明文进行填充补位 + @param text: 需要进行填充补位操作的明文 + @return: 补齐明文字符串 + """ + text_length = len(text) + # 计算需要填充的位数 + amount_to_pad = self.block_size - (text_length % self.block_size) + if amount_to_pad == 0: + amount_to_pad = self.block_size + # 获得补位所用的字符 + pad = chr(amount_to_pad) + return text + (pad * amount_to_pad).encode() + + def decode(self, decrypted): + """删除解密后明文的补位字符 + @param decrypted: 解密后的明文 + @return: 删除补位字符后的明文 + """ + pad = ord(decrypted[-1]) + if pad < 1 or pad > 32: + pad = 0 + return decrypted[:-pad] + + +class Prpcrypt(object): + """提供接收和推送给企业微信消息的加解密接口""" + + def __init__(self, key): + # self.key = base64.b64decode(key+"=") + self.key = key + # 设置加解密模式为AES的CBC模式 + self.mode = AES.MODE_CBC + + def encrypt(self, text, receiveid): + """对明文进行加密 + @param text: 需要加密的明文 + @return: 加密得到的字符串 + """ + # 16位随机字符串添加到明文开头 + text = text.encode() + text = self.get_random_str() + struct.pack('I', socket.htonl(len(text))) + text + receiveid.encode() + + # 使用自定义的填充方式对明文进行补位填充 + pkcs7 = PKCS7Encoder() + text = pkcs7.encode(text) + # 加密 + cryptor = AES.new(self.key, self.mode, self.key[:16]) + try: + ciphertext = cryptor.encrypt(text) + # 使用BASE64对加密后的字符串进行编码 + return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext) + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_EncryptAES_Error, None + + def decrypt(self, text, receiveid): + """对解密后的明文进行补位删除 + @param text: 密文 + @return: 删除填充补位后的明文 + """ + try: + cryptor = AES.new(self.key, self.mode, self.key[:16]) + # 使用BASE64对密文进行解码,然后AES-CBC解密 + plain_text = cryptor.decrypt(base64.b64decode(text)) + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_DecryptAES_Error, None + try: + pad = plain_text[-1] + # 去掉补位字符串 + # pkcs7 = PKCS7Encoder() + # plain_text = pkcs7.encode(plain_text) + # 去除16位随机字符串 + content = plain_text[16:-pad] + xml_len = socket.ntohl(struct.unpack('I', content[:4])[0]) + xml_content = content[4 : xml_len + 4] + from_receiveid = content[xml_len + 4 :] + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_IllegalBuffer, None + + if from_receiveid.decode('utf8') != receiveid: + return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None + return 0, xml_content + + def get_random_str(self): + """随机生成16位字符串 + @return: 16位字符串 + """ + return str(random.randint(1000000000000000, 9999999999999999)).encode() + + +class WXBizMsgCrypt(object): + # 构造函数 + def __init__(self, sToken, sEncodingAESKey, sReceiveId): + try: + self.key = base64.b64decode(sEncodingAESKey + '=') + assert len(self.key) == 32 + except Exception: + throw_exception('[error]: EncodingAESKey unvalid !', FormatException) + # return ierror.WXBizMsgCrypt_IllegalAesKey,None + self.m_sToken = sToken + self.m_sReceiveId = sReceiveId + + # 验证URL + # @param sMsgSignature: 签名串,对应URL参数的msg_signature + # @param sTimeStamp: 时间戳,对应URL参数的timestamp + # @param sNonce: 随机串,对应URL参数的nonce + # @param sEchoStr: 随机串,对应URL参数的echostr + # @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效 + # @return:成功0,失败返回对应的错误码 + + def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr): + sha1 = SHA1() + ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr) + if ret != 0: + return ret, None + if not signature == sMsgSignature: + return ierror.WXBizMsgCrypt_ValidateSignature_Error, None + pc = Prpcrypt(self.key) + ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId) + return ret, sReplyEchoStr + + def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None): + # 将企业回复用户的消息加密打包 + # @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串 + # @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间 + # @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce + # sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串, + # return:成功0,sEncryptMsg,失败返回对应的错误码None + pc = Prpcrypt(self.key) + ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId) + encrypt = encrypt.decode('utf8') + if ret != 0: + return ret, None + if timestamp is None: + timestamp = str(int(time.time())) + # 生成安全签名 + sha1 = SHA1() + ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt) + if ret != 0: + return ret, None + xmlParse = XMLParse() + return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce) + + def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce): + # 检验消息的真实性,并且获取解密后的明文 + # @param sMsgSignature: 签名串,对应URL参数的msg_signature + # @param sTimeStamp: 时间戳,对应URL参数的timestamp + # @param sNonce: 随机串,对应URL参数的nonce + # @param sPostData: 密文,对应POST请求的数据 + # xml_content: 解密后的原文,当return返回0时有效 + # @return: 成功0,失败返回对应的错误码 + # 验证安全签名 + xmlParse = XMLParse() + ret, encrypt = xmlParse.extract(sPostData) + if ret != 0: + return ret, None + sha1 = SHA1() + ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt) + if ret != 0: + return ret, None + if not signature == sMsgSignature: + return ierror.WXBizMsgCrypt_ValidateSignature_Error, None + pc = Prpcrypt(self.key) + ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId) + return ret, xml_content diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py new file mode 100644 index 0000000..4e2c6a2 --- /dev/null +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -0,0 +1,2129 @@ +import asyncio +import base64 +import json +import time +import traceback +import uuid +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +import re +from typing import Any, Callable, Optional, Tuple +from urllib.parse import unquote + +import httpx +from Crypto.Cipher import AES +from quart import Quart, request, Response, jsonify + +from langbot.libs.wecom_ai_bot_api import wecombotevent +from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt +from langbot.pkg.platform.logger import EventLogger + + +@dataclass +class StreamChunk: + """描述单次推送给企业微信的流式片段。""" + + # 需要返回给企业微信的文本内容 + content: str + + # 标记是否为最终片段,对应企业微信协议里的 finish 字段 + is_final: bool = False + + # 预留额外元信息,未来支持多模态扩展时可使用 + meta: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class StreamSession: + """维护一次企业微信流式会话的上下文。""" + + # 企业微信要求的 stream_id,用于标识后续刷新请求 + stream_id: str + + # 原始消息的 msgid,便于与流水线消息对应 + msg_id: str + + # 群聊会话标识(单聊时为空) + chat_id: Optional[str] + + # 触发消息的发送者 + user_id: Optional[str] + + # 会话创建时间 + created_at: float = field(default_factory=time.time) + + # 最近一次被访问的时间,cleanup 依据该值判断过期 + last_access: float = field(default_factory=time.time) + + # 将流水线增量结果缓存到队列,刷新请求逐条消费 + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + + # 是否已经完成(收到最终片段) + finished: bool = False + + # 缓存最近一次片段,处理重试或超时兜底 + last_chunk: Optional[StreamChunk] = None + + # 反馈 ID,用于接收用户点赞/点踩反馈 + feedback_id: Optional[str] = None + + # Dify 人工输入暂停态:runner 把 _form_data 传过来时填充。 + # 一旦设置,下次企微 followup 请求时返回 button_interaction 模板卡 + # 替代 stream chunk。点击按钮会回调 template_card_event,EventKey + # 就是 Dify 的 action_id。 + pending_form: Optional[dict] = None + + # template_card task_id(企微要求 button_interaction 必填且不可重复)。 + # 创建 pending_form 时生成;按钮点击回调里用来反查 session。 + pending_form_task_id: Optional[str] = None + + +class StreamSessionManager: + """管理 stream 会话的生命周期,并负责队列的生产消费。""" + + # Sessions with registered feedback_ids use a longer TTL to survive the + # full like → cancel → dislike feedback flow. Must align with the adapter's + # _stream_to_monitoring_msg TTL (wecombot.py). + _FEEDBACK_SESSION_TTL = 600 # 10 minutes + + def __init__(self, logger: EventLogger, ttl: int = 60) -> None: + self.logger = logger + + self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup + self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射 + self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话 + self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射 + # task_id (button_interaction template_card 的) -> stream_id 映射, + # 用于按钮点击回调里反查 pending_form。 + self._task_index: dict[str, str] = {} + + def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]: + if not msg_id: + return None + return self._msg_index.get(msg_id) + + def get_session(self, stream_id: str) -> Optional[StreamSession]: + return self._sessions.get(stream_id) + + def get_session_by_feedback_id(self, feedback_id: str) -> Optional[StreamSession]: + """根据 feedback_id 查找会话。 + + Args: + feedback_id: 企业微信反馈事件中的反馈 ID。 + + Returns: + Optional[StreamSession]: 找到的会话实例,未找到返回 None。 + """ + if not feedback_id: + return None + stream_id = self._feedback_index.get(feedback_id) + if stream_id: + return self._sessions.get(stream_id) + return None + + def register_feedback_id(self, stream_id: str, feedback_id: str) -> None: + """注册 feedback_id 与 stream_id 的映射。 + + Args: + stream_id: 企业微信流式会话 ID。 + feedback_id: 反馈 ID。 + """ + if feedback_id and stream_id: + self._feedback_index[feedback_id] = stream_id + + def set_pending_form(self, stream_id: str, form_data: dict, task_id: str) -> None: + """把 Dify 人工输入暂停态绑定到 stream session。 + + 下一次企微 followup 请求时,adapter 检测到 pending_form, + 返回 button_interaction 模板卡而不是 stream chunk。 + """ + session = self._sessions.get(stream_id) + if not session: + return + session.pending_form = form_data + session.pending_form_task_id = task_id + if task_id: + self._task_index[task_id] = stream_id + + def get_session_by_task_id(self, task_id: str) -> Optional[StreamSession]: + """按按钮点击回调里的 TaskId 反查 session。""" + if not task_id: + return None + stream_id = self._task_index.get(task_id) + if not stream_id: + return None + return self._sessions.get(stream_id) + + def clear_pending_form(self, stream_id: str) -> None: + """按钮点击消费完后清掉 pending_form,避免重复弹卡。""" + session = self._sessions.get(stream_id) + if not session: + return + task_id = session.pending_form_task_id + session.pending_form = None + session.pending_form_task_id = None + if task_id: + self._task_index.pop(task_id, None) + + def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]: + """根据企业微信回调创建或获取会话。 + + Args: + msg_json: 企业微信解密后的回调 JSON。 + + Returns: + Tuple[StreamSession, bool]: `StreamSession` 为会话实例,`bool` 指示是否为新建会话。 + + Example: + 在首次回调中调用,得到 `is_new=True` 后再触发流水线。 + """ + msg_id = msg_json.get('msgid', '') + if msg_id and msg_id in self._msg_index: + stream_id = self._msg_index[msg_id] + session = self._sessions.get(stream_id) + if session: + session.last_access = time.time() + return session, False + + stream_id = str(uuid.uuid4()) + session = StreamSession( + stream_id=stream_id, + msg_id=msg_id, + chat_id=msg_json.get('chatid'), + user_id=msg_json.get('from', {}).get('userid'), + ) + + if msg_id: + self._msg_index[msg_id] = stream_id + self._sessions[stream_id] = session + return session, True + + async def publish(self, stream_id: str, chunk: StreamChunk) -> bool: + """向 stream 队列写入新的增量片段。 + + Args: + stream_id: 企业微信分配的流式会话 ID。 + chunk: 待发送的增量片段。 + + Returns: + bool: 当流式队列存在并成功入队时返回 True。 + + Example: + 在收到模型增量后调用 `await manager.publish('sid', StreamChunk('hello'))`。 + """ + session = self._sessions.get(stream_id) + if not session: + return False + + session.last_access = time.time() + session.last_chunk = chunk + + try: + session.queue.put_nowait(chunk) + except asyncio.QueueFull: + # 默认无界队列,此处兜底防御 + await session.queue.put(chunk) + + if chunk.is_final: + session.finished = True + + return True + + async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[StreamChunk]: + """从队列中取出一个片段,若超时返回 None。 + + Args: + stream_id: 企业微信流式会话 ID。 + timeout: 取片段的最长等待时间(秒)。 + + Returns: + Optional[StreamChunk]: 成功时返回片段,超时或会话不存在时返回 None。 + + Example: + 企业微信刷新到达时调用,若队列有数据则立即返回 `StreamChunk`。 + """ + session = self._sessions.get(stream_id) + if not session: + return None + + session.last_access = time.time() + + try: + chunk = await asyncio.wait_for(session.queue.get(), timeout) + session.last_access = time.time() + if chunk.is_final: + session.finished = True + return chunk + except asyncio.TimeoutError: + if session.finished and session.last_chunk: + return session.last_chunk + return None + + def mark_finished(self, stream_id: str) -> None: + session = self._sessions.get(stream_id) + if session: + session.finished = True + session.last_access = time.time() + + def cleanup(self) -> None: + """定期清理过期会话,防止队列与映射无上限累积。 + + 已注册 feedback_id 的会话使用更长的 TTL,确保用户在点赞/取消/点踩流程中 + 不会因为 session 被提前清除而丢失上下文信息。 + """ + now = time.time() + expired: list[str] = [] + for stream_id, session in self._sessions.items(): + # Sessions with registered feedback_ids use a longer TTL + effective_ttl = self._FEEDBACK_SESSION_TTL if session.feedback_id else self.ttl + if now - session.last_access > effective_ttl: + expired.append(stream_id) + + for stream_id in expired: + session = self._sessions.pop(stream_id, None) + if not session: + continue + msg_id = session.msg_id + if msg_id and self._msg_index.get(msg_id) == stream_id: + self._msg_index.pop(msg_id, None) + # Clean up feedback index for expired sessions + if session.feedback_id: + self._feedback_index.pop(session.feedback_id, None) + + +def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes: + """Decrypt AES-256-CBC encrypted file data. + + Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py). + + Args: + encrypted_data: The raw encrypted bytes. + aes_key_str: Base64-encoded AES key (may lack padding). + + Returns: + Decrypted bytes with PKCS#7 padding removed. + """ + if not encrypted_data: + raise ValueError('encrypted_data is empty') + if not aes_key_str: + raise ValueError('aes_key is empty') + + # Python's base64.b64decode requires proper padding (length % 4 == 0). + # Node.js Buffer.from tolerates missing '=', so we must pad manually. + remainder = len(aes_key_str) % 4 + if remainder != 0: + aes_key_str = aes_key_str + '=' * (4 - remainder) + key = base64.b64decode(aes_key_str) + + iv = key[:16] + + cipher = AES.new(key, AES.MODE_CBC, iv) + + # Ensure encrypted data is aligned to AES block size (16 bytes). + # Node.js setAutoPadding(false) silently handles unaligned data, + # but PyCryptodome will raise an error. + block_size = 16 + data_remainder = len(encrypted_data) % block_size + if data_remainder != 0: + encrypted_data = encrypted_data + b'\x00' * (block_size - data_remainder) + + decrypted = cipher.decrypt(encrypted_data) + + # Remove PKCS#7 padding with validation + if len(decrypted) == 0: + raise ValueError('Decrypted data is empty') + + pad_len = decrypted[-1] + if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted): + raise ValueError(f'Invalid PKCS#7 padding value: {pad_len}') + + # Verify all padding bytes are consistent + for i in range(len(decrypted) - pad_len, len(decrypted)): + if decrypted[i] != pad_len: + raise ValueError('Invalid PKCS#7 padding: padding bytes mismatch') + + return decrypted[: len(decrypted) - pad_len] + + +def _extract_filename(content_disposition: str) -> Optional[str]: + """Extract filename from a Content-Disposition header value.""" + if not content_disposition: + return None + # RFC 5987: filename*=UTF-8''xxx + utf8_match = re.search(r"filename\*=UTF-8''([^;\s]+)", content_disposition, re.IGNORECASE) + if utf8_match: + return unquote(utf8_match.group(1)) + # Standard: filename="xxx" or filename=xxx + match = re.search(r'filename="?([^";\s]+)"?', content_disposition, re.IGNORECASE) + if match: + return unquote(match.group(1)) + return None + + +def _bytes_to_data_uri(data: bytes) -> str: + """Convert raw bytes to a data URI with auto-detected MIME type.""" + if data.startswith(b'\xff\xd8'): + mime_type = 'image/jpeg' + elif data.startswith(b'\x89PNG'): + mime_type = 'image/png' + elif data.startswith((b'GIF87a', b'GIF89a')): + mime_type = 'image/gif' + elif data.startswith(b'BM'): + mime_type = 'image/bmp' + elif data.startswith(b'II*\x00') or data.startswith(b'MM\x00*'): + mime_type = 'image/tiff' + elif data[:4] == b'%PDF': + mime_type = 'application/pdf' + elif data[:4] == b'PK\x03\x04': + mime_type = 'application/zip' + else: + mime_type = 'application/octet-stream' + + base64_str = base64.b64encode(data).decode('utf-8') + return f'data:{mime_type};base64,{base64_str}' + + +async def download_encrypted_file( + download_url: str, aes_key: str, logger: EventLogger +) -> Tuple[Optional[bytes], Optional[str]]: + """Download an AES-encrypted file from WeChat Work and decrypt it. + + Args: + download_url: The encrypted file download URL. + aes_key: The AES key for decryption (base64-encoded, per-message aeskey + or platform EncodingAESKey). + logger: Logger instance. + + Returns: + A tuple of (decrypted_bytes, filename) or (None, None) on failure. + """ + if not download_url: + return None, None + if not aes_key: + await logger.error('download_encrypted_file: aes_key is empty, cannot decrypt') + return None, None + + filename: Optional[str] = None + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(download_url) + if response.status_code != 200: + await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}') + return None, None + encrypted_bytes = response.content + filename = _extract_filename(response.headers.get('content-disposition', '')) + except Exception: + await logger.error(f'Failed to download file: {traceback.format_exc()}') + return None, None + + try: + decrypted = _decrypt_file(encrypted_bytes, aes_key) + return decrypted, filename + except Exception: + await logger.error(f'Failed to decrypt file: {traceback.format_exc()}') + return None, None + + +async def parse_wecom_bot_message( + msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger +) -> dict[str, Any]: + """Parse a decrypted WeChat Work AI Bot message JSON into a unified message dict. + + This is the shared message parsing logic used by both webhook and WebSocket modes. + + Args: + msg_json: The decrypted message JSON from WeChat Work. + encoding_aes_key: AES key for file decryption. + logger: Logger instance. + + Returns: + A dict suitable for constructing a WecomBotEvent. + """ + message_data: dict[str, Any] = {} + + msg_type = msg_json.get('msgtype', '') + if msg_type: + message_data['msgtype'] = msg_type + + if msg_json.get('chattype', '') == 'single': + message_data['type'] = 'single' + elif msg_json.get('chattype', '') == 'group': + message_data['type'] = 'group' + + max_inline_file_size = 5 * 1024 * 1024 + + async def _safe_download(url: str, per_msg_aeskey: str = '') -> Tuple[Optional[bytes], Optional[str]]: + """Download and decrypt a file, preferring per-message aeskey over platform key.""" + if not url: + return None, None + key = per_msg_aeskey or encoding_aes_key + if not key: + await logger.warning('No AES key available for file decryption, skipping download') + return None, None + return await download_encrypted_file(url, key, logger) + + async def _safe_download_as_data_uri(url: str, per_msg_aeskey: str = '') -> Optional[str]: + """Download, decrypt, and convert to data URI for backward compatibility.""" + data, _filename = await _safe_download(url, per_msg_aeskey) + if data: + return _bytes_to_data_uri(data) + return None + + if msg_type == 'text': + message_data['content'] = msg_json.get('text', {}).get('content') + elif msg_type == 'markdown': + message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get( + 'content', '' + ) + elif msg_type == 'image': + image_info = msg_json.get('image', {}) + picurl = image_info.get('url', '') + per_msg_aeskey = image_info.get('aeskey', '') + base64_data = await _safe_download_as_data_uri(picurl, per_msg_aeskey) + if base64_data: + message_data['picurl'] = base64_data + message_data['images'] = [base64_data] + elif msg_type == 'voice': + voice_info = msg_json.get('voice', {}) or {} + download_url = voice_info.get('url') + per_msg_aeskey = voice_info.get('aeskey', '') + message_data['voice'] = { + 'url': download_url, + 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), + 'filesize': voice_info.get('filesize') or voice_info.get('size'), + 'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'), + } + if voice_info.get('content'): + message_data['content'] = voice_info.get('content') + # if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size: + # voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey) + # if voice_base64: + # message_data['voice']['base64'] = voice_base64 + elif msg_type == 'video': + video_info = msg_json.get('video', {}) or {} + download_url = video_info.get('url') + per_msg_aeskey = video_info.get('aeskey', '') + video_data = { + 'url': download_url, + 'filesize': video_info.get('filesize') or video_info.get('size'), + 'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'), + 'md5sum': video_info.get('md5sum') or video_info.get('md5'), + 'filename': video_info.get('filename') or video_info.get('name'), + } + # if (video_data.get('filesize') or 0) <= max_inline_file_size: + # video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey) + # if video_base64: + # video_data['base64'] = video_base64 + # 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑 + video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}' + message_data['video'] = video_data + elif msg_type == 'file': + file_info = msg_json.get('file', {}) or {} + download_url = file_info.get('url') or file_info.get('fileurl') + per_msg_aeskey = file_info.get('aeskey', '') + file_data = { + 'filename': file_info.get('filename') or file_info.get('name'), + 'filesize': file_info.get('filesize') or file_info.get('size'), + 'md5sum': file_info.get('md5sum') or file_info.get('md5'), + 'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'), + 'download_url': download_url, + 'extra': file_info, + } + # if (file_data.get('filesize') or 0) <= max_inline_file_size: + # file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey) + # if file_bytes: + # file_data['base64'] = _bytes_to_data_uri(file_bytes) + # if dl_filename and not file_data.get('filename'): + # file_data['filename'] = dl_filename + + # 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑 + file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}' + message_data['file'] = file_data + elif msg_type == 'link': + message_data['link'] = msg_json.get('link', {}) + if not message_data.get('content'): + title = message_data['link'].get('title', '') + desc = message_data['link'].get('description') or message_data['link'].get('digest', '') + message_data['content'] = '\n'.join(filter(None, [title, desc])) + elif msg_type == 'mixed': + items = msg_json.get('mixed', {}).get('msg_item', []) + texts = [] + images = [] + files = [] + voices = [] + videos = [] + links = [] + for item in items: + item_type = item.get('msgtype') + if item_type == 'text': + texts.append(item.get('text', {}).get('content', '')) + elif item_type == 'image': + img_info = item.get('image', {}) + img_url = img_info.get('url') + img_aeskey = img_info.get('aeskey', '') + base64_data = await _safe_download_as_data_uri(img_url, img_aeskey) + if base64_data: + images.append(base64_data) + elif item_type == 'file': + file_info = item.get('file', {}) or {} + download_url = file_info.get('url') or file_info.get('fileurl') + item_aeskey = file_info.get('aeskey', '') + file_data = { + 'filename': file_info.get('filename') or file_info.get('name'), + 'filesize': file_info.get('filesize') or file_info.get('size'), + 'md5sum': file_info.get('md5sum') or file_info.get('md5'), + 'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'), + 'download_url': download_url, + 'extra': file_info, + } + if (file_data.get('filesize') or 0) <= max_inline_file_size: + file_bytes, dl_filename = await _safe_download(download_url, item_aeskey) + if file_bytes: + file_data['base64'] = _bytes_to_data_uri(file_bytes) + if dl_filename and not file_data.get('filename'): + file_data['filename'] = dl_filename + files.append(file_data) + elif item_type == 'voice': + voice_info = item.get('voice', {}) or {} + download_url = voice_info.get('url') + item_aeskey = voice_info.get('aeskey', '') + voice_data = { + 'url': download_url, + 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), + 'filesize': voice_info.get('filesize') or voice_info.get('size'), + 'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'), + } + if voice_info.get('content'): + texts.append(voice_info.get('content')) + if (voice_data.get('filesize') or 0) <= max_inline_file_size: + voice_base64 = await _safe_download_as_data_uri(download_url, item_aeskey) + if voice_base64: + voice_data['base64'] = voice_base64 + voices.append(voice_data) + elif item_type == 'video': + video_info = item.get('video', {}) or {} + download_url = video_info.get('url') + item_aeskey = video_info.get('aeskey', '') + video_data = { + 'url': download_url, + 'filesize': video_info.get('filesize') or video_info.get('size'), + 'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'), + 'md5sum': video_info.get('md5sum') or video_info.get('md5'), + 'filename': video_info.get('filename') or video_info.get('name'), + } + if (video_data.get('filesize') or 0) <= max_inline_file_size: + video_base64 = await _safe_download_as_data_uri(download_url, item_aeskey) + if video_base64: + video_data['base64'] = video_base64 + videos.append(video_data) + elif item_type == 'link': + links.append(item.get('link', {})) + + if texts: + message_data['content'] = ' '.join(texts) + if images: + message_data['images'] = images + message_data['picurl'] = images[0] + if files: + message_data['files'] = files + message_data['file'] = files[0] + if voices: + message_data['voices'] = voices + message_data['voice'] = voices[0] + if videos: + message_data['videos'] = videos + message_data['video'] = videos[0] + if links: + message_data['link'] = links[0] + if items: + message_data['attachments'] = items + else: + message_data['raw_msg'] = msg_json + + from_info = msg_json.get('from', {}) + message_data['userid'] = from_info.get('userid', '') + message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '') + + if msg_json.get('chattype', '') == 'group': + message_data['chatid'] = msg_json.get('chatid', '') + message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '') + + message_data['msgid'] = msg_json.get('msgid', '') + + if msg_json.get('aibotid'): + message_data['aibotid'] = msg_json.get('aibotid', '') + + # Handle quote (referenced message) - important for group chat file references + quote_info = msg_json.get('quote') + if quote_info: + quote_data: dict[str, Any] = {} + quote_type = quote_info.get('msgtype', '') + quote_data['msgtype'] = quote_type + + if quote_type == 'text': + quote_data['content'] = quote_info.get('text', {}).get('content', '') + elif quote_type == 'image': + img_info = quote_info.get('image', {}) + img_url = img_info.get('url', '') + img_aeskey = img_info.get('aeskey', '') + base64_data = await _safe_download_as_data_uri(img_url, img_aeskey) + if base64_data: + quote_data['picurl'] = base64_data + quote_data['images'] = [base64_data] + elif quote_type == 'file': + file_info = quote_info.get('file', {}) or {} + download_url = file_info.get('url') or file_info.get('fileurl') + item_aeskey = file_info.get('aeskey', '') + file_data = { + 'filename': file_info.get('filename') or file_info.get('name'), + 'filesize': file_info.get('filesize') or file_info.get('size'), + 'md5sum': file_info.get('md5sum') or file_info.get('md5'), + 'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'), + 'download_url': download_url, + 'extra': file_info, + } + # Same as private chat: append aeskey to download_url for plugin processing + if download_url and item_aeskey: + file_data['download_url'] = download_url + f'?aeskey={item_aeskey}' + quote_data['file'] = file_data + elif quote_type == 'voice': + voice_info = quote_info.get('voice', {}) or {} + download_url = voice_info.get('url') + item_aeskey = voice_info.get('aeskey', '') + voice_data = { + 'url': download_url, + 'md5sum': voice_info.get('md5sum') or voice_info.get('md5'), + 'filesize': voice_info.get('filesize') or voice_info.get('size'), + 'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'), + } + if voice_info.get('content'): + quote_data['content'] = voice_info.get('content') + # Same as private chat: append aeskey to url for plugin processing + if download_url and item_aeskey: + voice_data['url'] = download_url + f'?aeskey={item_aeskey}' + quote_data['voice'] = voice_data + elif quote_type == 'video': + video_info = quote_info.get('video', {}) or {} + download_url = video_info.get('url') + item_aeskey = video_info.get('aeskey', '') + video_data = { + 'url': download_url, + 'filesize': video_info.get('filesize') or video_info.get('size'), + 'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'), + 'md5sum': video_info.get('md5sum') or video_info.get('md5'), + 'filename': video_info.get('filename') or video_info.get('name'), + } + # Same as private chat: append aeskey to download_url for plugin processing + if download_url and item_aeskey: + video_data['download_url'] = download_url + f'?aeskey={item_aeskey}' + quote_data['video'] = video_data + elif quote_type == 'link': + quote_data['link'] = quote_info.get('link', {}) + link = quote_data['link'] + title = link.get('title', '') + desc = link.get('description') or link.get('digest', '') + quote_data['content'] = '\n'.join(filter(None, [title, desc])) + elif quote_type == 'mixed': + # Handle mixed type in quote (text + images + files etc.) + items = quote_info.get('mixed', {}).get('msg_item', []) + texts = [] + images = [] + files = [] + for item in items: + item_type = item.get('msgtype') + if item_type == 'text': + texts.append(item.get('text', {}).get('content', '')) + elif item_type == 'image': + img_info = item.get('image', {}) + img_url = img_info.get('url') + img_aeskey = img_info.get('aeskey', '') + base64_data = await _safe_download_as_data_uri(img_url, img_aeskey) + if base64_data: + images.append(base64_data) + elif item_type == 'file': + file_info = item.get('file', {}) or {} + download_url = file_info.get('url') or file_info.get('fileurl') + item_aeskey = file_info.get('aeskey', '') + file_data = { + 'filename': file_info.get('filename') or file_info.get('name'), + 'filesize': file_info.get('filesize') or file_info.get('size'), + 'md5sum': file_info.get('md5sum') or file_info.get('md5'), + 'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'), + 'download_url': download_url, + 'extra': file_info, + } + # Same as private chat: append aeskey to download_url for plugin processing + if download_url and item_aeskey: + file_data['download_url'] = download_url + f'?aeskey={item_aeskey}' + files.append(file_data) + if texts: + quote_data['content'] = ' '.join(texts) + if images: + quote_data['images'] = images + quote_data['picurl'] = images[0] + if files: + quote_data['files'] = files + quote_data['file'] = files[0] + + message_data['quote'] = quote_data + + return message_data + + +def _wecom_button_style(action: dict, *, selected: bool = False) -> int: + """Map Dify button style to WeCom button style.""" + + if not selected: + return 2 + + return 1 + + +def _wecom_field_display_name(field: dict, fallback: str = '') -> str: + label = ( + field.get('label') or field.get('title') or field.get('name') or field.get('output_variable_name') or fallback + ) + return str(label or fallback).strip() + + +def _wecom_input_hint_lines(form_data: dict) -> list[str]: + lines: list[str] = [] + current_field = str(form_data.get('_current_input_field') or '').strip() + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + field_type = str(field.get('type') or 'text').strip().lower() + field_label = _wecom_field_display_name(field, field_name) + if current_field and field_name != current_field: + continue + if not field_name: + continue + if field_type in {'file', 'file-list'}: + limit = field.get('number_limits') if field_type == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if field_type == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + lines.append(f'- {field_label}: upload file(s){allowed}{suffix} or reply `{field_name}: `') + return lines + + +def _wecom_pending_input_defs(form_data: dict) -> list[dict]: + if form_data.get('_action_select_only'): + return [] + inputs = form_data.get('inputs') or {} + current_field = str(form_data.get('_current_input_field') or '').strip() + pending = [] + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + if current_field and field_name != current_field: + continue + if str(field.get('type') or '').strip().lower() in {'file', 'file-list'}: + continue + if inputs.get(field_name) in (None, '', []): + pending.append(field) + return pending + + +def _wecom_select_options(field: dict) -> list[str]: + source = field.get('option_source') or {} + options = source.get('value') if isinstance(source, dict) else [] + if not isinstance(options, list): + return [] + return [str(option) for option in options] + + +def _wecom_select_option_id(index: int) -> str: + return f'opt_{index + 1}' + + +def _wecom_pending_select_defs(form_data: dict) -> list[dict]: + return [ + field + for field in _wecom_pending_input_defs(form_data) + if str(field.get('type') or '').strip().lower() == 'select' and _wecom_select_options(field) + ] + + +def _wecom_field_title(field: dict, fallback: str) -> str: + title = _wecom_field_display_name(field, fallback) + return str(title or fallback).strip()[:13] or fallback + + +def _wecom_form_desc(form_data: dict) -> str: + form_content = _wecom_clean_form_content(form_data) + return form_content[:512] if form_content else '' + + +def build_human_input_text_prompt(form_data: dict) -> Optional[str]: + """Build a plain-text prompt for a current non-select input field.""" + + current_field = str(form_data.get('_current_input_field') or '').strip() + if not current_field: + return None + for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: + if str(field.get('output_variable_name') or '').strip() != current_field: + continue + field_type = str(field.get('type') or 'text').strip().lower() + if field_type == 'select': + return None + form_content = _wecom_clean_form_content(form_data) + if not form_content: + form_content = _wecom_field_display_name(field, current_field) + node_title = str(form_data.get('node_title') or '人工介入').strip() + return f'{node_title}\n\n{form_content}' if form_content else node_title + return None + + +def build_multiple_interaction_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, +) -> dict[str, Any]: + """Build a WeCom multiple_interaction card for pending select fields.""" + + select_fields = _wecom_pending_select_defs(form_data) + node_title = (form_data.get('node_title') or '').strip() or 'Human Input' + inputs = form_data.get('inputs') or {} + + select_list = [] + for field_index, field in enumerate(select_fields[:10]): + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + options = _wecom_select_options(field)[:10] + option_list = [ + { + 'id': _wecom_select_option_id(idx), + 'text': option_text[:10] or _wecom_select_option_id(idx), + } + for idx, option_text in enumerate(options) + ] + selected_id = _wecom_select_option_id(0) + current_value = inputs.get(field_name) + if current_value not in (None, '', []): + for idx, option_text in enumerate(options): + if str(current_value) == option_text: + selected_id = _wecom_select_option_id(idx) + break + select_list.append( + { + 'question_key': field_name, + 'title': _wecom_field_title(field, f'Select {field_index + 1}'), + 'selected_id': selected_id, + 'option_list': option_list, + } + ) + + card: dict[str, Any] = { + 'card_type': 'multiple_interaction', + 'main_title': { + 'title': node_title, + 'desc': _wecom_form_desc(form_data), + }, + 'select_list': select_list, + 'submit_button': { + 'text': 'Submit', + 'key': 'submit_human_input', + }, + 'task_id': task_id, + } + if source: + card['source'] = source + return { + 'msgtype': 'template_card', + 'template_card': card, + } + + +_SELECT_BUTTON_KEY_PREFIX = '__dify_select__' + + +def _encode_select_button_key(field_name: str, option_index: int) -> str: + data = json.dumps({'f': field_name, 'i': option_index}, ensure_ascii=False, separators=(',', ':')) + encoded = base64.urlsafe_b64encode(data.encode('utf-8')).decode('ascii').rstrip('=') + return f'{_SELECT_BUTTON_KEY_PREFIX}:{encoded}' + + +def parse_select_button_action(action_id: str, form_data: dict) -> dict[str, str]: + """Decode a select option represented as a button_interaction click.""" + + action_id = str(action_id or '').strip() + prefix = f'{_SELECT_BUTTON_KEY_PREFIX}:' + if not action_id.startswith(prefix): + return {} + encoded = action_id[len(prefix) :] + try: + padded = encoded + '=' * (-len(encoded) % 4) + data = json.loads(base64.urlsafe_b64decode(padded.encode('ascii')).decode('utf-8')) + except Exception: + return {} + field_name = str(data.get('f') or '').strip() + option_index = data.get('i') + if not field_name or not isinstance(option_index, int): + return {} + for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: + if str(field.get('output_variable_name') or '').strip() != field_name: + continue + options = _wecom_select_options(field) + if 0 <= option_index < len(options): + return {field_name: options[option_index]} + return {} + + +def build_select_button_interaction_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, +) -> dict[str, Any]: + """Build a button_interaction card that emulates a select field. + + WeCom AI Bot long-connection callbacks are reliable for button clicks, so + this is used as a fallback when multiple_interaction submit callbacks are + not delivered by the platform. + """ + + select_fields = _wecom_pending_select_defs(form_data) + field = select_fields[0] if select_fields else {} + field_name = str(field.get('output_variable_name') or '').strip() + options = _wecom_select_options(field)[:10] if field else [] + visible_options = options[:6] + overflow_options = options[6:] + + node_title = (form_data.get('node_title') or '').strip() or 'Human Input' + form_content = _wecom_clean_form_content(form_data) + + sub_title_parts: list[str] = [] + if form_content: + sub_title_parts.append(form_content) + if overflow_options: + extra_lines = [f' - {idx + 7}. {option}' for idx, option in enumerate(overflow_options)] + sub_title_parts.append( + 'More options can be entered by replying with the option text:\n' + '\n'.join(extra_lines) + ) + + button_list = [ + { + 'text': option_text[:10] or f'Option {idx + 1}', + 'style': 2 if idx == 0 else 0, + 'key': _encode_select_button_key(field_name, idx), + } + for idx, option_text in enumerate(visible_options) + ] + + card: dict[str, Any] = { + 'card_type': 'button_interaction', + 'main_title': { + 'title': node_title, + }, + 'sub_title_text': '\n\n'.join(sub_title_parts), + 'button_list': button_list, + 'task_id': task_id, + } + if source: + card['source'] = source + return { + 'msgtype': 'template_card', + 'template_card': card, + } + + +def build_human_input_template_card_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, + select_as_buttons: bool = False, +) -> dict[str, Any]: + """Build the best WeCom template card for a Dify human-input form.""" + + if _wecom_pending_select_defs(form_data): + if select_as_buttons: + return build_select_button_interaction_payload(form_data, task_id, source=source) + return build_multiple_interaction_payload(form_data, task_id, source=source) + return build_button_interaction_payload(form_data, task_id, source=source) + + +def _wecom_clean_form_content(form_data: dict) -> str: + is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') + raw_content = str(form_data.get('raw_form_content') or '') + content = form_data.get('form_content') or raw_content + input_defs = list(form_data.get('all_input_defs') or form_data.get('input_defs') or []) + fields = { + str(field.get('output_variable_name') or '').strip(): field + for field in input_defs + if str(field.get('output_variable_name') or '').strip() + } + + if form_data.get('_action_select_only') and raw_content: + placeholders = [ + match + for match in re.finditer(r'\{\{#\$output\.([^#{}]+)#\}\}', raw_content) + if match.group(1).strip() in fields + ] + if placeholders: + content = raw_content[placeholders[-1].end() :] + + if is_field_step: + inputs = form_data.get('inputs') or {} + + def replace_placeholder(match: re.Match[str]) -> str: + field_name = match.group(1).strip() + field = fields.get(field_name) + value = inputs.get(field_name) + if not field or value in (None, '', []): + return '' + return f'✅ {field_name}:{_wecom_display_input_value(field, value)}' + + content = re.sub(r'\{\{#\$output\.([^#{}]+)#\}\}', replace_placeholder, str(content)) + + kept_lines: list[str] = [] + for line in str(content).splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1).strip() in fields: + continue + kept_lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) + + +def _wecom_display_input_value(field: dict, value: Any) -> str: + field_type = str(field.get('type') or 'text').strip().lower() + if field_type == 'file': + if isinstance(value, dict): + return str(value.get('url') or value.get('upload_file_id') or '1 file') + elif field_type == 'file-list' and isinstance(value, list): + return f'{len(value)} file(s)' + return str(value) + + +def build_button_interaction_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, +) -> dict[str, Any]: + """Build a `template_card` (button_interaction) WeCom payload. + + Shared by both the webhook-mode client (returns the payload as the + response to a stream-followup callback) and the ws_client (sends it + as a reply frame). Output shape is `{"msgtype": "template_card", + "template_card": {...}}` per the WeCom spec. + + Args: + form_data: Dify human-input form data with keys ``actions`` (list of + ``{id, title, button_style}``), ``node_title``, ``form_content``. + task_id: Unique per-card identifier. WeCom requires this for + button_interaction. The click callback returns it as TaskId so we + can find the originating session. + source: Optional source header dict ``{icon_url, desc, desc_color}`` + shown at the top of the card. WeCom accepts arbitrary HTTPS + URLs for ``icon_url`` (unlike DingTalk Avatar which requires + a uploaded media id), so the LangBot logo URL can be passed + straight through. + + Notes: + * ``button.key`` is set directly to the Dify ``action_id``. The click + callback's ``EventKey`` carries this back unchanged (1024-byte limit + per the spec, far more than we ever need). + * WeCom caps the button list at 6. Extra actions are appended to + ``sub_title_text`` so users can still reply with the id as text. + * Styles map ``primary``→1 (blue), ``danger``→2 (red), default→0 + (gray). First button is auto-promoted to primary when no style. + """ + actions = list(form_data.get('actions') or []) + node_title = (form_data.get('node_title') or '').strip() or '人工介入' + form_content = _wecom_clean_form_content(form_data) + should_show_actions = not _wecom_pending_input_defs(form_data) + + visible_actions = actions[:6] if should_show_actions else [] + overflow = actions[6:] if should_show_actions else [] + + sub_title_parts: list[str] = [] + if form_content: + sub_title_parts.append(form_content) + input_hint_lines = _wecom_input_hint_lines(form_data) + if input_hint_lines: + sub_title_parts.append('Fill these fields in chat before choosing an action:\n' + '\n'.join(input_hint_lines)) + if overflow: + extra_lines = [f' - {a.get("title") or a.get("id") or ""} (回复 id: {a.get("id") or ""})' for a in overflow] + sub_title_parts.append(f'另有 {len(overflow)} 个选项不在按钮列表中,可直接回复 id:\n' + '\n'.join(extra_lines)) + sub_title_text = '\n\n'.join(sub_title_parts) + + button_list = [] + for idx, action in enumerate(visible_actions): + action_id = str(action.get('id') or '') + title = str(action.get('title') or action_id or f'选项 {idx + 1}') + button_list.append( + { + 'text': title, + 'style': _wecom_button_style(action), + 'key': action_id, + } + ) + + card: dict[str, Any] = { + 'card_type': 'button_interaction', + 'main_title': { + 'title': node_title, + }, + 'sub_title_text': sub_title_text, + 'button_list': button_list, + 'task_id': task_id, + } + if source: + card['source'] = source + return { + 'msgtype': 'template_card', + 'template_card': card, + } + + +def extract_template_card_action(tce: dict[str, Any]) -> tuple[str, str, str]: + """Extract task id, clicked button key, and card type from a WeCom callback.""" + + task_id = tce.get('TaskId') or tce.get('task_id') or tce.get('taskid') or tce.get('taskId') or '' + event_key = ( + tce.get('EventKey') + or tce.get('event_key') + or tce.get('eventkey') + or tce.get('eventKey') + or tce.get('key') + or tce.get('Key') + or '' + ) + card_type = tce.get('CardType') or tce.get('card_type') or tce.get('cardtype') or tce.get('cardType') or '' + + for button_key in ('button', 'Button', 'selected_button', 'selectedButton'): + button = tce.get(button_key) + if isinstance(button, dict): + if not event_key: + event_key = ( + button.get('key') + or button.get('Key') + or button.get('event_key') + or button.get('EventKey') + or button.get('id') + or button.get('Id') + or '' + ) + break + + return str(task_id or ''), str(event_key or ''), str(card_type or '') + + +def extract_wecom_event_type(payload: dict[str, Any]) -> str: + """Extract eventtype from common WeCom callback wrapper shapes.""" + + event = payload.get('event') if isinstance(payload, dict) else {} + if not isinstance(event, dict): + event = {} + event_type = ( + event.get('eventtype') + or event.get('event_type') + or event.get('eventType') + or event.get('EventType') + or payload.get('eventtype') + or payload.get('event_type') + or payload.get('eventType') + or payload.get('EventType') + or '' + ) + if event_type: + return str(event_type) + + tce = extract_template_card_event_payload(payload) + task_id, event_key, card_type = extract_template_card_action(tce) + if task_id or event_key or card_type or extract_template_card_selections(tce): + return 'template_card_event' + return '' + + +def extract_template_card_event_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Extract template_card_event from common WeCom callback wrapper shapes.""" + + if not isinstance(payload, dict): + return {} + event = payload.get('event') if isinstance(payload.get('event'), dict) else {} + candidates = ( + event.get('template_card_event'), + event.get('templateCardEvent'), + event.get('TemplateCardEvent'), + event.get('template_card'), + payload.get('template_card_event'), + payload.get('templateCardEvent'), + payload.get('TemplateCardEvent'), + payload.get('template_card'), + ) + for candidate in candidates: + if isinstance(candidate, dict): + return candidate + if any( + key in payload + for key in ( + 'TaskId', + 'task_id', + 'taskId', + 'EventKey', + 'event_key', + 'eventKey', + 'CardType', + 'card_type', + 'cardType', + 'ResponseData', + 'response_data', + 'select_list', + 'SelectList', + ) + ): + return payload + if any( + key in event + for key in ( + 'TaskId', + 'task_id', + 'taskId', + 'EventKey', + 'event_key', + 'eventKey', + 'CardType', + 'card_type', + 'cardType', + 'ResponseData', + 'response_data', + 'select_list', + 'SelectList', + ) + ): + return event + return {} + + +def extract_template_card_selections(tce: dict[str, Any], form_data: Optional[dict] = None) -> dict[str, str]: + """Extract multiple_interaction select values from a WeCom callback. + + WeCom callback examples differ between webhook and websocket docs, so this + parser accepts common snake_case/camelCase/PascalCase variants and maps the + selected option id back to the Dify select option text when form_data is + available. + """ + + fields_by_name: dict[str, dict] = {} + if form_data: + for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if field_name: + fields_by_name[field_name] = field + + def _maybe_decode_json(value: Any) -> Any: + if not isinstance(value, str): + return value + text = value.strip() + if not text or text[0] not in '[{': + return value + try: + return json.loads(text) + except json.JSONDecodeError: + return value + + def _walk(value: Any) -> list[dict]: + value = _maybe_decode_json(value) + found: list[dict] = [] + if isinstance(value, dict): + found.append(value) + for child in value.values(): + found.extend(_walk(child)) + elif isinstance(value, list): + for child in value: + found.extend(_walk(child)) + return found + + def _lookup(item: dict, *keys: str) -> Any: + for key in keys: + if key in item: + return item.get(key) + lower_map = {str(key).lower(): value for key, value in item.items()} + for key in keys: + lowered = key.lower() + if lowered in lower_map: + return lower_map[lowered] + return '' + + def _normalise_selected_value(question_key: str, selected: Any) -> str: + selected = _maybe_decode_json(selected) + if isinstance(selected, dict): + selected = _lookup( + selected, + 'selected_id', + 'SelectedId', + 'selected_option_id', + 'SelectedOptionId', + 'option_id', + 'OptionId', + 'id', + 'Id', + 'value', + 'Value', + ) + selected_id = str(selected or '').strip() + if not selected_id: + return '' + selected_value = selected_id + field = fields_by_name.get(question_key) + if field: + options = _wecom_select_options(field) + for idx, option_text in enumerate(options): + if selected_id in {_wecom_select_option_id(idx), option_text}: + selected_value = option_text + break + return selected_value + + selections: dict[str, str] = {} + for item in _walk(tce): + question_key = _lookup( + item, + 'question_key', + 'questionKey', + 'QuestionKey', + 'question', + 'Question', + 'key', + 'Key', + ) + selected_id = _lookup( + item, + 'selected_id', + 'selectedId', + 'SelectedId', + 'selected_option_id', + 'selectedOptionId', + 'SelectedOptionId', + 'option_id', + 'optionId', + 'OptionId', + 'value', + 'Value', + ) + question_key = str(question_key or '').strip() + selected_id = str(selected_id or '').strip() + if question_key not in fields_by_name or not selected_id: + continue + + selected_value = _normalise_selected_value(question_key, selected_id) + if not selected_value: + continue + selections[question_key] = selected_value + + # Some WeCom callbacks encode ResponseData as a direct mapping: + # {"xiala": "id_two"} rather than a select_list item array. + for item in _walk(tce): + if not isinstance(item, dict): + continue + for question_key, selected in item.items(): + question_key = str(question_key or '').strip() + if question_key not in fields_by_name or question_key in selections: + continue + selected_value = _normalise_selected_value(question_key, selected) + if selected_value: + selections[question_key] = selected_value + + return selections + + +def resolve_form_action_title(form_data: dict, action_id: str) -> str: + """Resolve a Dify form action title from its id.""" + + clean_action_id = str(action_id or '').strip() + for action in form_data.get('actions') or []: + if str(action.get('id', '')) == clean_action_id: + return str(action.get('title') or clean_action_id) + return clean_action_id + + +def build_button_interaction_update_card( + form_data: dict, + task_id: str, + action_id: str, + source: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Build the template_card body used to update a clicked form card.""" + + node_title = str(form_data.get('node_title') or '').strip() or '人工介入' + form_content = _wecom_clean_form_content(form_data) + action_title = resolve_form_action_title(form_data, action_id) + clean_action_id = str(action_id or '').strip() + + button_list = [] + matched = False + for idx, action in enumerate(list(form_data.get('actions') or [])[:6]): + action_key = str(action.get('id') or '') + button_title = str(action.get('title') or action_key or f'Option {idx + 1}') + button = { + 'text': button_title, + 'style': _wecom_button_style(action), + 'key': action_key, + } + if action_key == clean_action_id: + button['style'] = _wecom_button_style(action, selected=True) + button['text'] = f'✅ {button_title}' + button['replace_text'] = f'✅ {button_title}' + matched = True + button_list.append(button) + + if clean_action_id and not matched: + button_list.append( + { + 'text': action_title or clean_action_id, + 'style': 1, + 'key': clean_action_id, + 'replace_text': f'✅ {action_title or clean_action_id}', + } + ) + + card: dict[str, Any] = { + 'card_type': 'button_interaction', + 'main_title': { + 'title': node_title, + }, + 'sub_title_text': form_content, + 'button_list': button_list, + 'task_id': task_id, + } + if source: + card['source'] = source + return card + + +def build_multiple_interaction_update_card( + form_data: dict, + task_id: str, + selections: Optional[dict[str, str]] = None, + source: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Build an update card that freezes submitted select values.""" + + node_title = str(form_data.get('node_title') or '').strip() or 'Human Input' + selected_values = dict(form_data.get('inputs') or {}) + selected_values.update(selections or {}) + + select_list = [] + fields = _wecom_pending_select_defs(form_data) + if not fields: + fields = [ + field + for field in (form_data.get('input_defs') or form_data.get('all_input_defs') or []) + if str(field.get('type') or '').strip().lower() == 'select' + ] + for field_index, field in enumerate(fields[:10]): + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + options = _wecom_select_options(field)[:10] + option_list = [ + { + 'id': _wecom_select_option_id(idx), + 'text': option_text[:10] or _wecom_select_option_id(idx), + } + for idx, option_text in enumerate(options) + ] + selected_id = _wecom_select_option_id(0) + current_value = selected_values.get(field_name) + if current_value not in (None, '', []): + for idx, option_text in enumerate(options): + if str(current_value) == option_text or str(current_value) == _wecom_select_option_id(idx): + selected_id = _wecom_select_option_id(idx) + break + select_list.append( + { + 'question_key': field_name, + 'title': _wecom_field_title(field, f'Select {field_index + 1}'), + 'disable': True, + 'selected_id': selected_id, + 'option_list': option_list, + } + ) + + display_form_data = dict(form_data) + display_form_data['inputs'] = selected_values + form_content = _wecom_clean_form_content(display_form_data) + + card: dict[str, Any] = { + 'card_type': 'multiple_interaction', + 'main_title': { + 'title': node_title, + 'desc': form_content, + }, + 'select_list': select_list, + 'submit_button': { + 'text': '✅', + 'key': 'submit_human_input', + }, + 'task_id': task_id, + } + if source: + card['source'] = source + return card + + +class WecomBotClient: + def __init__( + self, + Token: str, + EnCodingAESKey: str, + Corpid: str, + logger: EventLogger, + unified_mode: bool = False, + ): + """企业微信智能机器人客户端。 + + Args: + Token: 企业微信回调验证使用的 token。 + EnCodingAESKey: 企业微信消息加解密密钥。 + Corpid: 企业 ID。 + logger: 日志记录器。 + unified_mode: 是否使用统一 webhook 模式(默认 False)。 + + Example: + >>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger) + """ + + self.Token = Token + self.EnCodingAESKey = EnCodingAESKey + self.Corpid = Corpid + self.ReceiveId = '' + self.unified_mode = unified_mode + self.app = Quart(__name__) + + # 只有在非统一模式下才注册独立路由 + if not self.unified_mode: + self.app.add_url_rule( + '/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET'] + ) + + self._message_handlers = { + 'example': [], + } + self.logger = logger + self.generated_content: dict[str, str] = {} + self.msg_id_map: dict[str, int] = {} + self.stream_sessions = StreamSessionManager(logger=logger) + self.stream_poll_timeout = 0.5 + + self._feedback_callback: Optional[Callable] = None + self._card_action_callback: Optional[Callable] = None + self._stream_last_content: dict[str, str] = {} + # Optional `source` block injected into every interactive template_card + # the client builds. Set via `set_card_source` from the adapter after + # reading config. Format: {icon_url, desc, desc_color}. + self.card_source: Optional[dict] = None + + def set_card_source(self, source: Optional[dict]) -> None: + """Set the `source` header dict injected into every + button_interaction template_card. Pass None to clear.""" + self.card_source = source + + def set_feedback_callback(self, callback: Callable) -> None: + """设置反馈回调函数。 + + Args: + callback: 反馈回调函数,签名: async def callback(feedback_id, feedback_type, feedback_content, inaccurate_reasons, session) + """ + self._feedback_callback = callback + + def set_card_action_callback(self, callback: Callable) -> None: + """设置按钮卡片点击回调函数。 + + Signature: ``async def callback(session, action_id, task_id, raw_event) -> None`` + + ``session`` is the StreamSession the card was attached to; + ``action_id`` is the Dify action_id reflected back via the + button's ``key`` field; ``task_id`` is the card's task_id + (matches ``session.pending_form_task_id``); ``raw_event`` is the + decoded callback JSON for any extra fields the adapter wants. + """ + self._card_action_callback = callback + + @staticmethod + def _build_stream_payload( + stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None + ) -> dict[str, Any]: + """按照企业微信协议拼装返回报文。 + + Args: + stream_id: 企业微信会话 ID。 + content: 推送的文本内容。 + finish: 是否为最终片段。 + feedback_id: 反馈 ID,用于接收用户点赞/点踩反馈。 + + Returns: + dict[str, Any]: 可直接加密返回的 payload。 + + Example: + 组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。 + """ + stream_payload = { + 'id': stream_id, + 'finish': finish, + 'content': content, + } + if feedback_id: + stream_payload['feedback'] = {'id': feedback_id} + return { + 'msgtype': 'stream', + 'stream': stream_payload, + } + + def _build_button_interaction_payload(self, form_data: dict, task_id: str) -> dict[str, Any]: + """Class-level shim — delegates to module-level builder and auto- + injects the client's configured `source` block so every card emitted + through this client carries the LangBot header.""" + return build_human_input_template_card_payload(form_data, task_id, source=self.card_source) + + async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]: + """对响应进行加密封装并返回给企业微信。 + + Args: + payload: 待加密的响应内容。 + nonce: 企业微信回调参数中的 nonce。 + + Returns: + Tuple[Response, int]: Quart Response 对象及状态码。 + + Example: + 在首包或刷新场景中调用以生成加密响应。 + """ + reply_plain_str = json.dumps(payload, ensure_ascii=False) + reply_timestamp = str(int(time.time())) + ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp) + if ret != 0: + await self.logger.error(f'加密失败: {ret}') + return jsonify({'error': 'encrypt_failed'}), 500 + + root = ET.fromstring(encrypt_text) + encrypt = root.find('Encrypt').text + resp = { + 'encrypt': encrypt, + } + return jsonify(resp), 200 + + async def _dispatch_event(self, event: wecombotevent.WecomBotEvent) -> None: + """异步触发流水线处理,避免阻塞首包响应。 + + Args: + event: 由企业微信消息转换的内部事件对象。 + """ + try: + await self._handle_message(event) + except Exception: + await self.logger.error(traceback.format_exc()) + + async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + """处理企业微信首次推送的消息,返回 stream_id 并开启流水线。 + + Args: + msg_json: 解密后的企业微信消息 JSON。 + nonce: 企业微信回调参数 nonce。 + + Returns: + Tuple[Response, int]: Quart Response 及状态码。 + + Example: + 首次回调时调用,立即返回带 `stream_id` 的响应。 + """ + session, is_new = self.stream_sessions.create_or_get(msg_json) + + feedback_id = str(uuid.uuid4()) + session.feedback_id = feedback_id + self.stream_sessions.register_feedback_id(session.stream_id, feedback_id) + + message_data = await self.get_message(msg_json) + if message_data: + message_data['stream_id'] = session.stream_id + message_data['feedback_id'] = feedback_id + try: + event = wecombotevent.WecomBotEvent(message_data) + except Exception: + await self.logger.error(traceback.format_exc()) + else: + if is_new: + asyncio.create_task(self._dispatch_event(event)) + + payload = self._build_stream_payload(session.stream_id, '', False, feedback_id) + return await self._encrypt_and_reply(payload, nonce) + + async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + """处理企业微信的流式刷新请求,按需返回增量片段。 + + Args: + msg_json: 解密后的企业微信刷新请求。 + nonce: 企业微信回调参数 nonce。 + + Returns: + Tuple[Response, int]: Quart Response 及状态码。 + + Example: + 在刷新请求中调用,按需返回增量片段。 + """ + stream_info = msg_json.get('stream', {}) + stream_id = stream_info.get('id', '') + if not stream_id: + await self.logger.error('刷新请求缺少 stream.id') + return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce) + + session = self.stream_sessions.get_session(stream_id) + + # If a Dify human-input pause arrived during this stream, switch + # the response from `msgtype: stream` to `msgtype: template_card` + # (button_interaction). The session's stream is also marked + # finished so future followups aren't expected (assuming the + # WeCom client treats template_card as the terminal response — + # we'll know from the next callback whether it kept polling). + if session and session.pending_form and session.pending_form_task_id: + await self.logger.info( + f'WeComBot: returning button_interaction for stream_id={stream_id} ' + f'task_id={session.pending_form_task_id} actions={len(session.pending_form.get("actions") or [])}' + ) + card_payload = self._build_button_interaction_payload(session.pending_form, session.pending_form_task_id) + self.stream_sessions.mark_finished(stream_id) + return await self._encrypt_and_reply(card_payload, nonce) + + chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout) + + if not chunk: + cached_content = None + if session and session.msg_id: + cached_content = self.generated_content.pop(session.msg_id, None) + if cached_content is not None: + chunk = StreamChunk(content=cached_content, is_final=True) + else: + payload = self._build_stream_payload(stream_id, '', False) + return await self._encrypt_and_reply(payload, nonce) + + payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final) + if chunk.is_final: + self.stream_sessions.mark_finished(stream_id) + return await self._encrypt_and_reply(payload, nonce) + + async def handle_callback_request(self): + """企业微信回调入口(独立端口模式,使用全局 request)。 + + Returns: + Quart Response: 根据请求类型返回验证、首包或刷新结果。 + + Example: + 作为 Quart 路由处理函数直接注册并使用。 + """ + return await self._handle_callback_internal(request) + + async def handle_unified_webhook(self, req): + """处理回调请求(统一 webhook 模式,显式传递 request)。 + + Args: + req: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 + + Args: + req: Quart Request 对象 + """ + try: + self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '') + + if req.method == 'GET': + return await self._handle_get_callback(req) + + if req.method == 'POST': + return await self._handle_post_callback(req) + + return Response('', status=405) + + except Exception: + await self.logger.error(traceback.format_exc()) + return Response('Internal Server Error', status=500) + + async def _handle_get_callback(self, req) -> tuple[Response, int] | Response: + """处理企业微信的 GET 验证请求。""" + + msg_signature = unquote(req.args.get('msg_signature', '')) + timestamp = unquote(req.args.get('timestamp', '')) + nonce = unquote(req.args.get('nonce', '')) + echostr = unquote(req.args.get('echostr', '')) + + if not all([msg_signature, timestamp, nonce, echostr]): + await self.logger.error('请求参数缺失') + return Response('缺少参数', status=400) + + ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + if ret != 0: + await self.logger.error('验证URL失败') + return Response('验证失败', status=403) + + return Response(decrypted_str, mimetype='text/plain') + + async def _handle_post_callback(self, req) -> tuple[Response, int] | Response: + """处理企业微信的 POST 回调请求。""" + + self.stream_sessions.cleanup() + + msg_signature = unquote(req.args.get('msg_signature', '')) + timestamp = unquote(req.args.get('timestamp', '')) + nonce = unquote(req.args.get('nonce', '')) + + encrypted_json = await req.get_json() + encrypted_msg = (encrypted_json or {}).get('encrypt', '') + if not encrypted_msg: + await self.logger.error("请求体中缺少 'encrypt' 字段") + return Response('Bad Request', status=400) + + xml_post_data = f'' + ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) + if ret != 0: + await self.logger.error('解密失败') + return Response('解密失败', status=400) + + msg_json = json.loads(decrypted_xml) + + event_type = extract_wecom_event_type(msg_json) + + if event_type == 'feedback_event': + return await self._handle_feedback_event(msg_json, nonce) + + # Button click on a button_interaction template_card. The WeCom doc + # calls this `template_card_event`; some routes wrap the button + # event payload inside `event.template_card_event`. + if event_type == 'template_card_event': + return await self._handle_template_card_event(msg_json, nonce) + + if msg_json.get('msgtype') == 'stream': + return await self._handle_post_followup_response(msg_json, nonce) + + return await self._handle_post_initial_response(msg_json, nonce) + + async def _handle_template_card_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + """Handle a button click on a button_interaction template_card. + + WeCom carries the click info in ``event.template_card_event`` with + ``TaskId`` matching the card we created and ``EventKey`` carrying + the button's ``key`` (which we set to the Dify ``action_id``). + """ + try: + tce = extract_template_card_event_payload(msg_json) + task_id, event_key, card_type = extract_template_card_action(tce) + + await self.logger.info(f'收到按钮点击: task_id={task_id} event_key={event_key!r} card_type={card_type}') + + session = self.stream_sessions.get_session_by_task_id(task_id) + if session is None: + await self.logger.warning(f'未找到 task_id={task_id} 对应的 session,按钮点击被丢弃') + else: + if self._card_action_callback is not None: + try: + await self._card_action_callback(session, event_key, task_id, msg_json) + except Exception: + await self.logger.error(f'card action callback raised: {traceback.format_exc()}') + # Drop the form so a fresh chunk/followup doesn't re-render + # the same card (and so the task_id can be GC'd). + self.stream_sessions.clear_pending_form(session.stream_id) + except Exception: + await self.logger.error(f'_handle_template_card_event error: {traceback.format_exc()}') + + # WeCom expects an empty success ack for event callbacks. + return await self._encrypt_and_reply({}, nonce) + + async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + """处理企业微信用户反馈事件(点赞/点踩)。 + + Args: + msg_json: 解密后的企业微信反馈事件 JSON。 + nonce: 企业微信回调参数 nonce。 + + Returns: + Tuple[Response, int]: Quart Response 及状态码。 + + Note: + 企业微信协议要求:反馈事件目前仅支持回复空包。 + """ + try: + feedback_event = msg_json.get('event', {}).get('feedback_event', {}) + feedback_id = feedback_event.get('id', '') + feedback_type = feedback_event.get('type', 0) + feedback_content = feedback_event.get('content', '') + inaccurate_reasons = feedback_event.get('inaccurate_reason_list', []) + + await self.logger.info( + f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, ' + f'content={feedback_content}, reasons={inaccurate_reasons}' + ) + + session = self.stream_sessions.get_session_by_feedback_id(feedback_id) + + if session: + await self.logger.info( + f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}' + ) + else: + await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈') + + # Dispatch feedback event regardless of session availability + for handler in self._message_handlers.get('feedback', []): + try: + await handler( + feedback_id=feedback_id, + feedback_type=feedback_type, + feedback_content=feedback_content, + inaccurate_reasons=inaccurate_reasons, + session=session, + ) + except Exception: + await self.logger.error(traceback.format_exc()) + + if self._feedback_callback: + try: + await self._feedback_callback( + feedback_id=feedback_id, + feedback_type=feedback_type, + feedback_content=feedback_content, + inaccurate_reasons=inaccurate_reasons, + session=session, + ) + except Exception: + await self.logger.error(traceback.format_exc()) + + except Exception: + await self.logger.error(traceback.format_exc()) + + return await self._encrypt_and_reply({}, nonce) + + async def get_message(self, msg_json): + return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger) + + async def _handle_message(self, event: wecombotevent.WecomBotEvent): + """ + 处理消息事件。 + """ + try: + message_id = event.message_id + if message_id in self.msg_id_map.keys(): + self.msg_id_map[message_id] += 1 + return + self.msg_id_map[message_id] = 1 + msg_type = event.type + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + except Exception: + print(traceback.format_exc()) + + async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: + """将流水线片段推送到 stream 会话。 + + Args: + msg_id: 原始企业微信消息 ID。 + content: 模型产生的片段内容。 + is_final: 是否为最终片段。 + + Returns: + bool: 当成功写入流式队列时返回 True。 + + Example: + 在流水线 `reply_message_chunk` 中调用,将增量推送至企业微信。 + """ + # 根据 msg_id 找到对应 stream 会话,如果不存在说明当前消息非流式 + stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id) + if not stream_id: + return False + + previous_content = self._stream_last_content.get(msg_id, '') + if previous_content and content.startswith(previous_content): + next_content = content + elif previous_content and not content: + next_content = previous_content + else: + next_content = previous_content + content if previous_content else content + + if not is_final and next_content == previous_content: + return True + + # Follow-up responses replace the displayed stream body in WeCom. + # Publish the complete snapshot so earlier chunks remain visible. + chunk = StreamChunk(content=next_content, is_final=is_final) + await self.stream_sessions.publish(stream_id, chunk) + self._stream_last_content[msg_id] = next_content + if is_final: + self._stream_last_content.pop(msg_id, None) + self.stream_sessions.mark_finished(stream_id) + return True + + async def push_form_pause( + self, msg_id: str, form_data: dict, task_id: Optional[str] = None + ) -> tuple[bool, Optional[str], Optional[str]]: + """Attach a Dify human-input pause to the active stream session. + + On the next WeCom followup poll, the response switches from + ``msgtype: stream`` to ``msgtype: template_card`` (button_interaction) + carrying the buttons. ``task_id`` is auto-generated if not provided + and is what the button-click callback uses to look the session back up. + + Returns: + ``(ok, stream_id, task_id)``. ``ok`` is False if the + adapter's msg_id maps to no stream session (e.g. non-stream mode). + """ + stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id) + if not stream_id: + return False, None, None + if not task_id: + # WeCom requires task_id [A-Za-z0-9_-@], <= 128 bytes, unique per bot. + task_id = f'dify-{uuid.uuid4().hex[:24]}' + self.stream_sessions.set_pending_form(stream_id, form_data, task_id) + return True, stream_id, task_id + + async def set_message(self, msg_id: str, content: str): + """兼容旧逻辑:若无法流式返回则缓存最终结果。 + + Args: + msg_id: 企业微信消息 ID。 + content: 最终回复的文本内容。 + + Example: + 在非流式场景下缓存最终结果以备刷新时返回。 + """ + handled = await self.push_stream_chunk(msg_id, content, is_final=True) + if not handled: + self.generated_content[msg_id] = content + + def on_message(self, msg_type: str): + def decorator(func: Callable[[wecombotevent.WecomBotEvent], None]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + + return decorator + + def on_feedback(self): + def decorator(func: Callable): + if 'feedback' not in self._message_handlers: + self._message_handlers['feedback'] = [] + self._message_handlers['feedback'].append(func) + return func + + return decorator + + async def download_url_to_base64(self, download_url, encoding_aes_key): + data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger) + if data: + return _bytes_to_data_uri(data) + return None + + async def run_task(self, host: str, port: int, *args, **kwargs): + """ + 启动 Quart 应用。 + """ + await self.app.run_task(host=host, port=port, *args, **kwargs) diff --git a/src/langbot/libs/wecom_ai_bot_api/ierror.py b/src/langbot/libs/wecom_ai_bot_api/ierror.py new file mode 100644 index 0000000..6c7ca12 --- /dev/null +++ b/src/langbot/libs/wecom_ai_bot_api/ierror.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +######################################################################### +# Author: jonyqin +# Created Time: Thu 11 Sep 2014 01:53:58 PM CST +# File Name: ierror.py +# Description:定义错误码含义 +######################################################################### +WXBizMsgCrypt_OK = 0 +WXBizMsgCrypt_ValidateSignature_Error = -40001 +WXBizMsgCrypt_ParseXml_Error = -40002 +WXBizMsgCrypt_ComputeSignature_Error = -40003 +WXBizMsgCrypt_IllegalAesKey = -40004 +WXBizMsgCrypt_ValidateCorpid_Error = -40005 +WXBizMsgCrypt_EncryptAES_Error = -40006 +WXBizMsgCrypt_DecryptAES_Error = -40007 +WXBizMsgCrypt_IllegalBuffer = -40008 +WXBizMsgCrypt_EncodeBase64_Error = -40009 +WXBizMsgCrypt_DecodeBase64_Error = -40010 +WXBizMsgCrypt_GenReturnXml_Error = -40011 diff --git a/src/langbot/libs/wecom_ai_bot_api/wecombotevent.py b/src/langbot/libs/wecom_ai_bot_api/wecombotevent.py new file mode 100644 index 0000000..d70dd9e --- /dev/null +++ b/src/langbot/libs/wecom_ai_bot_api/wecombotevent.py @@ -0,0 +1,156 @@ +from typing import Dict, Any, Optional + + +class WecomBotEvent(dict): + @staticmethod + def from_payload(payload: Dict[str, Any]) -> Optional['WecomBotEvent']: + try: + event = WecomBotEvent(payload) + return event + except KeyError: + return None + + @property + def type(self) -> str: + """ + 事件类型 + """ + return self.get('type', '') + + @property + def msgtype(self) -> str: + """ + 消息 msgtype + """ + return self.get('msgtype', '') + + @property + def userid(self) -> str: + """ + 用户id + """ + return self.get('from', {}).get('userid', '') or self.get('userid', '') + + @property + def username(self) -> str: + """ + 用户名称 + """ + return ( + self.get('username', '') + or self.get('from', {}).get('alias', '') + or self.get('from', {}).get('name', '') + or self.userid + ) + + @property + def chatname(self) -> str: + """ + 群组名称 + """ + return self.get('chatname', '') or str(self.chatid) + + @property + def content(self) -> str: + """ + 内容 + """ + return self.get('content', '') + + @property + def picurl(self) -> str: + """ + 图片url + """ + return self.get('picurl', '') + + @property + def images(self): + """ + 图片列表(兼容 mixed) + """ + return self.get('images', []) + + @property + def file(self): + """ + 文件信息 + """ + return self.get('file', {}) + + @property + def voice(self): + """ + 语音信息 + """ + return self.get('voice', {}) + + @property + def video(self): + """ + 视频信息 + """ + return self.get('video', {}) + + @property + def link(self): + """ + 链接消息信息 + """ + return self.get('link', {}) + + @property + def location(self): + """ + 位置信息 + """ + return self.get('location', {}) + + @property + def attachments(self): + """ + 原始 mixed 中的附件项 + """ + return self.get('attachments', []) + + @property + def chatid(self) -> str: + """ + 群组id + """ + return self.get('chatid', {}) + + @property + def message_id(self) -> str: + """ + 消息id + """ + return self.get('msgid', '') + + @property + def ai_bot_id(self) -> str: + """ + AI Bot ID + """ + return self.get('aibotid', '') + + @property + def feedback_id(self) -> str: + """ + 反馈 ID,用于关联用户点赞/点踩反馈 + """ + return self.get('feedback_id', '') + + @property + def stream_id(self) -> str: + """ + 流式消息 ID + """ + return self.get('stream_id', '') + + @property + def quote(self): + """ + 引用消息信息(群聊中用户引用其他消息时返回) + """ + return self.get('quote', {}) diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py new file mode 100644 index 0000000..32c11bd --- /dev/null +++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py @@ -0,0 +1,962 @@ +"""WeChat Work AI Bot WebSocket long connection client. + +Implements the WebSocket protocol for receiving messages and sending replies +via a persistent connection to wss://openws.work.weixin.qq.com, as an +alternative to the HTTP callback (webhook) mode. + +Protocol reference: https://developer.work.weixin.qq.com/document/path/101463 +Official Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk +""" + +from __future__ import annotations + +import asyncio +import json +import secrets +import time +import traceback +from typing import Any, Callable, Optional + +import aiohttp + +from langbot.libs.wecom_ai_bot_api import wecombotevent +from langbot.libs.wecom_ai_bot_api.api import ( + parse_wecom_bot_message, + StreamSession, + build_human_input_template_card_payload, + build_human_input_text_prompt, + build_button_interaction_update_card, + build_multiple_interaction_update_card, + extract_template_card_action, + extract_template_card_event_payload, + extract_template_card_selections, + extract_wecom_event_type, + parse_select_button_action, +) +from langbot.pkg.platform.logger import EventLogger + +DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com' + +# WebSocket frame command constants +CMD_SUBSCRIBE = 'aibot_subscribe' +CMD_HEARTBEAT = 'ping' +CMD_MSG_CALLBACK = 'aibot_msg_callback' +CMD_EVENT_CALLBACK = 'aibot_event_callback' +CMD_RESPOND_MSG = 'aibot_respond_msg' +CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg' +CMD_RESPOND_UPDATE = 'aibot_respond_update_msg' +CMD_SEND_MSG = 'aibot_send_msg' + + +def _generate_req_id(prefix: str) -> str: + """Generate a unique request ID in the format: {prefix}_{timestamp}_{random}.""" + ts = int(time.time() * 1000) + rand = secrets.token_hex(4) + return f'{prefix}_{ts}_{rand}' + + +def _frame_snippet(frame: dict, limit: int = 1000) -> str: + return json.dumps(frame, ensure_ascii=False, default=str)[:limit] + + +class WecomBotWsClient: + """WeChat Work AI Bot WebSocket long connection client. + + Provides message receiving, streaming reply, proactive message sending, + and event callback handling over a persistent WebSocket connection. + """ + + def __init__( + self, + bot_id: str, + secret: str, + logger: EventLogger, + encoding_aes_key: str = '', + ws_url: str = DEFAULT_WS_URL, + heartbeat_interval: float = 30.0, + max_reconnect_attempts: int = -1, + reconnect_base_delay: float = 1.0, + reconnect_max_delay: float = 30.0, + ): + self.bot_id = bot_id + self.secret = secret + self.logger = logger + self.encoding_aes_key = encoding_aes_key + self.ws_url = ws_url + self.heartbeat_interval = heartbeat_interval + self.max_reconnect_attempts = max_reconnect_attempts + self.reconnect_base_delay = reconnect_base_delay + self.reconnect_max_delay = reconnect_max_delay + + self._ws: Optional[aiohttp.ClientWebSocketResponse] = None + self._session: Optional[aiohttp.ClientSession] = None + self._running = False + self._heartbeat_task: Optional[asyncio.Task] = None + self._missed_pong_count = 0 + self._max_missed_pong = 2 + self._reconnect_attempts = 0 + + # Message handler registry (same pattern as WecomBotClient) + self._message_handlers: dict[str, list[Callable]] = {} + # Message deduplication + self._msg_id_map: dict[str, int] = {} + + # Pending ACK futures: req_id -> Future[dict] + self._pending_acks: dict[str, asyncio.Future] = {} + # Per-req_id serial reply queues + self._reply_queues: dict[str, asyncio.Queue] = {} + self._reply_workers: dict[str, asyncio.Task] = {} + self._reply_ack_timeout = 5.0 + + # Stream ID tracking for WebSocket mode + self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id + # Dedup: skip sending when content hasn't changed + self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent + # Stream session info for feedback tracking + self._stream_sessions: dict[str, dict] = {} # msg_id -> session info + # Feedback tracking: feedback_id -> session info + self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id} + # msg_id -> feedback_id (for associating feedback with message) + self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id + + # Dify human-input pause state for ws mode. Keys are task_id (echoed + # back in template_card_event.TaskId so we can rebuild the session + # context on click). + # task_id -> {form_data, msg_id, user_id, chat_id, stream_id, req_id} + self._pending_forms_by_task: dict[str, dict] = {} + # Reverse: msg_id -> task_id (for cleanup when stream finishes). + self._task_id_by_msg: dict[str, str] = {} + # Optional card-action callback registered by the adapter. + # Signature mirrors the http-mode WecomBotClient: + # async def callback(session, action_id, task_id, raw_event) -> None + self._card_action_callback: Optional[Callable] = None + # Optional `source` block injected into every interactive + # template_card the client builds via `push_form_pause`. Set via + # `set_card_source` from the adapter after reading config. + self.card_source: Optional[dict] = None + + # ── Public API ────────────────────────────────────────────────── + + async def connect(self): + """Connect to WebSocket server with automatic reconnection. + + This method blocks until disconnect() is called or max reconnect + attempts are exhausted. + """ + self._running = True + self._reconnect_attempts = 0 + + while self._running: + try: + await self._connect_once() + except Exception: + if not self._running: + break + await self.logger.error(f'WebSocket connection error: {traceback.format_exc()}') + + if not self._running: + break + + # Reconnect with exponential backoff + if self.max_reconnect_attempts != -1 and self._reconnect_attempts >= self.max_reconnect_attempts: + await self.logger.error(f'Max reconnect attempts reached ({self.max_reconnect_attempts}), giving up') + break + + self._reconnect_attempts += 1 + delay = min( + self.reconnect_base_delay * (2 ** (self._reconnect_attempts - 1)), + self.reconnect_max_delay, + ) + await self.logger.info(f'Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempts})...') + await asyncio.sleep(delay) + + async def disconnect(self): + """Gracefully disconnect from the WebSocket server.""" + self._running = False + if self._heartbeat_task and not self._heartbeat_task.done(): + self._heartbeat_task.cancel() + for task in self._reply_workers.values(): + if not task.done(): + task.cancel() + if self._ws and not self._ws.closed: + await self._ws.close() + self._ws = None + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + def on_message(self, msg_type: str) -> Callable: + """Decorator to register a message handler. + + Same interface as WecomBotClient.on_message for compatibility. + + Args: + msg_type: 'single', 'group', or specific message type. + """ + + def decorator(func: Callable[[wecombotevent.WecomBotEvent], Any]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + + return decorator + + def on_feedback(self) -> Callable: + """Decorator to register a feedback event handler. + + Same interface as WecomBotClient.on_feedback for compatibility. + """ + + def decorator(func: Callable): + if 'feedback' not in self._message_handlers: + self._message_handlers['feedback'] = [] + self._message_handlers['feedback'].append(func) + return func + + return decorator + + async def reply_stream( + self, + req_id: str, + stream_id: str, + content: str, + finish: bool = False, + feedback_id: str = '', + ) -> Optional[dict]: + """Send a streaming reply frame. + + Args: + req_id: The req_id from the original message frame (must be passed through). + stream_id: The stream ID for this streaming session. + content: The content to send (supports Markdown). + finish: Whether this is the final chunk. + feedback_id: Optional feedback ID for receiving user feedback (like/dislike). + + Returns: + The ACK frame dict, or None on failure. + """ + stream_payload = { + 'id': stream_id, + 'finish': finish, + 'content': content, + } + if feedback_id: + stream_payload['feedback'] = {'id': feedback_id} + + body = { + 'msgtype': 'stream', + 'stream': stream_payload, + } + return await self._send_reply(req_id, body) + + async def reply_text(self, req_id: str, content: str) -> Optional[dict]: + """Send a non-streaming text reply. + + Args: + req_id: The req_id from the original message frame. + content: The text content to reply. + + Returns: + The ACK frame dict, or None on failure. + """ + body = { + 'msgtype': 'markdown', + 'markdown': { + 'content': content, + }, + } + return await self._send_reply(req_id, body) + + async def reply_template_card(self, req_id: str, card_payload: dict[str, Any]) -> Optional[dict]: + """Send a template_card (button_interaction etc.) reply. + + Args: + req_id: The req_id from the original message frame. + card_payload: Body produced by ``build_button_interaction_payload``; + must contain ``msgtype`` and ``template_card`` keys. + + Returns: + ACK frame dict, or None on failure. + """ + return await self._send_reply(req_id, card_payload) + + async def update_template_card( + self, + req_id: str, + template_card: dict[str, Any], + ) -> Optional[dict]: + """Update an existing template_card via WebSocket. + + Uses the ``aibot_respond_update_msg`` command. Must be called + within 5 seconds of receiving the ``template_card_event`` callback, + using the **same req_id** from that callback. + + The ``template_card`` dict should contain ``card_type`` and the + new content fields (e.g. ``main_title``, ``button_list`` with + disabled buttons and ``replace_text``). + + Returns: + ACK frame dict, or None on failure. + """ + body: dict[str, Any] = { + 'response_type': 'update_template_card', + 'template_card': template_card, + } + return await self._send_reply(req_id, body, cmd=CMD_RESPOND_UPDATE) + + def set_card_action_callback(self, callback: Callable) -> None: + """Register the button-click handler. + + ``async def callback(session, action_id, task_id, raw_event) -> None`` + — same signature as the http-mode WecomBotClient version so the + adapter can hand both off to the same coroutine. + """ + self._card_action_callback = callback + + def set_card_source(self, source: Optional[dict]) -> None: + """Set the `source` block injected into every interactive + template_card pushed via `push_form_pause`. Pass None to clear.""" + self.card_source = source + + async def push_form_pause( + self, msg_id: str, form_data: dict, task_id: Optional[str] = None + ) -> tuple[bool, Optional[str], Optional[str]]: + """Attach a Dify human-input pause to the active stream and send + the button_interaction card immediately. + + ws mode has no notion of polled "followup" responses — each reply + is a one-shot frame send. So unlike the http path (which defers + card delivery to the next followup), here we just craft the card + and reply with it on the original req_id. The corresponding stream + session is then torn down so subsequent chunks don't re-send. + + Returns: + ``(ok, stream_id, task_id)``. ``ok=False`` if no active stream + for this msg_id (e.g. message arrived in non-stream mode). + """ + key = self._stream_ids.get(msg_id) + if not key: + return False, None, None + req_id, stream_id = key.split('|', 1) + + if not task_id: + task_id = f'dify-{secrets.token_hex(12)}' + + session_info = self._stream_sessions.get(msg_id) or {} + text_prompt = build_human_input_text_prompt(form_data) + if text_prompt: + try: + ack = await self.reply_text(req_id, text_prompt) + if ack is None: + return False, stream_id, None + except Exception: + await self.logger.error(f'Failed to send human-input text prompt: {traceback.format_exc()}') + return False, stream_id, None + + self._stream_ids.pop(msg_id, None) + self._stream_last_content.pop(msg_id, None) + self._stream_sessions.pop(msg_id, None) + return True, stream_id, None + + self._pending_forms_by_task[task_id] = { + 'form_data': form_data, + 'msg_id': msg_id, + 'user_id': session_info.get('user_id', ''), + 'chat_id': session_info.get('chat_id', ''), + 'stream_id': stream_id, + 'req_id': req_id, + } + self._task_id_by_msg[msg_id] = task_id + + card_payload = build_human_input_template_card_payload( + form_data, + task_id, + source=self.card_source, + select_as_buttons=True, + ) + try: + await self.reply_template_card(req_id, card_payload) + except Exception: + await self.logger.error(f'Failed to send button_interaction card: {traceback.format_exc()}') + # Roll back the bookkeeping so the next attempt isn't blocked. + self._pending_forms_by_task.pop(task_id, None) + self._task_id_by_msg.pop(msg_id, None) + return False, stream_id, None + + # Tear down the stream — WeCom expects either stream chunks OR a + # template_card, not both on the same req_id. Subsequent + # push_stream_chunk calls for this msg_id become no-ops. + self._stream_ids.pop(msg_id, None) + self._stream_last_content.pop(msg_id, None) + # Keep _stream_sessions so the button callback can still resolve + # user/chat context; it gets cleaned up when the click fires. + + return True, stream_id, task_id + + async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]: + """Proactively send a message to a specified chat. + + Args: + chat_id: The chat ID (userid for single chat, chatid for group chat). + content: The message content. + msgtype: Message type, 'markdown' by default. + + Returns: + The ACK frame dict, or None on failure. + """ + req_id = _generate_req_id(CMD_SEND_MSG) + body: dict[str, Any] = { + 'chatid': chat_id, + 'msgtype': msgtype, + } + if msgtype == 'markdown': + body['markdown'] = {'content': content} + elif msgtype == 'text': + body['text'] = {'content': content} + return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG) + + async def send_template_card(self, chat_id: str, card_payload: dict[str, Any]) -> Optional[dict]: + """Proactively push a template_card to a chat. + + Used for the resumed-workflow path (button click → new query): + synthetic events have no inbound req_id to reply against, so we + fall back to proactive ``aibot_send_msg`` instead of reply mode. + + Args: + chat_id: userid (single chat) or chatid (group chat). + card_payload: ``{"msgtype": "template_card", "template_card": {...}}`` + as produced by :func:`build_button_interaction_payload`. + """ + req_id = _generate_req_id(CMD_SEND_MSG) + body = dict(card_payload) + body['chatid'] = chat_id + return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG) + + async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: + """Push a streaming chunk for a given message ID. + + Compatible interface with WecomBotClient.push_stream_chunk. + + Args: + msg_id: The original message ID. + content: The cumulative content from the pipeline. + is_final: Whether this is the final chunk. + + Returns: + True if the stream session exists and chunk was sent. + """ + key = self._stream_ids.get(msg_id) + if not key: + return False + req_id, stream_id = key.split('|', 1) + try: + previous_content = self._stream_last_content.get(msg_id, '') + if previous_content and content.startswith(previous_content): + next_content = content + elif previous_content and not content: + next_content = previous_content + else: + next_content = previous_content + content if previous_content else content + + # Skip sending if content hasn't changed (e.g. during tool call argument streaming) + if not is_final and next_content == previous_content: + return True + + # Skip empty/whitespace-only snapshots — the runner injects a + # zero-width space ('​') as a pass-through when workflow_paused + # fires without any preceding LLM output. WeCom renders that + # as an empty bubble that sits before the form card; skip it. + # NOTE: Python str.strip() does NOT strip ​, so we use + # a regex that treats any character with Unicode category Zs + # (separator space) or Cf (format char like ZWS) as blank. + if not is_final: + import re as _re + + if not _re.sub(r'[\s​‌‍]', '', next_content): + return True + + # Generate feedback_id for final chunk + feedback_id = '' + if is_final: + feedback_id = _generate_req_id('feedback') + self._msg_feedback_ids[msg_id] = feedback_id + # Store session info for feedback tracking + session_info = self._stream_sessions.get(msg_id) + if session_info: + self._feedback_sessions[feedback_id] = session_info + + # WeCom replaces the displayed stream content on each refresh, so + # every frame must contain the complete snapshot, not only a delta. + await self.reply_stream(req_id, stream_id, next_content, finish=is_final, feedback_id=feedback_id) + self._stream_last_content[msg_id] = next_content + if is_final: + self._stream_ids.pop(msg_id, None) + self._stream_last_content.pop(msg_id, None) + self._stream_sessions.pop(msg_id, None) + return True + except Exception: + await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}') + return False + + async def set_message(self, msg_id: str, content: str): + """Fallback: send content as a final stream chunk or direct reply. + + Compatible interface with WecomBotClient.set_message. + """ + handled = await self.push_stream_chunk(msg_id, content, is_final=True) + if not handled: + await self.logger.warning(f'No active stream for msg_id={msg_id}, message dropped') + + # ── Connection lifecycle ──────────────────────────────────────── + + async def _connect_once(self): + """Establish a single WebSocket connection, authenticate, and listen.""" + await self.logger.info(f'Connecting to {self.ws_url}...') + + self._session = aiohttp.ClientSession() + try: + self._ws = await self._session.ws_connect(self.ws_url) + self._missed_pong_count = 0 + self._reconnect_attempts = 0 + await self.logger.info('WebSocket connected, sending auth...') + + await self._send_auth() + + # Wait for auth response + auth_ok = await self._wait_for_auth() + if not auth_ok: + await self.logger.error('Authentication failed') + return + + await self.logger.info('Authenticated successfully') + + # Start heartbeat + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + + try: + await self._listen_loop() + finally: + if self._heartbeat_task and not self._heartbeat_task.done(): + self._heartbeat_task.cancel() + self._clear_pending_acks('Connection closed') + finally: + if self._ws and not self._ws.closed: + await self._ws.close() + self._ws = None + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + async def _send_auth(self): + """Send the authentication frame.""" + frame = { + 'cmd': CMD_SUBSCRIBE, + 'headers': {'req_id': _generate_req_id(CMD_SUBSCRIBE)}, + 'body': { + 'bot_id': self.bot_id, + 'secret': self.secret, + }, + } + await self._send_frame(frame) + + async def _wait_for_auth(self) -> bool: + """Wait for and validate the authentication response.""" + try: + msg = await asyncio.wait_for(self._ws.receive(), timeout=10.0) + if msg.type in (aiohttp.WSMsgType.TEXT,): + frame = json.loads(msg.data) + req_id = frame.get('headers', {}).get('req_id', '') + if req_id.startswith(CMD_SUBSCRIBE) and frame.get('errcode') == 0: + return True + await self.logger.error(f'Auth response: errcode={frame.get("errcode")}, errmsg={frame.get("errmsg")}') + return False + elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING): + await self.logger.error(f'WebSocket closed during auth: {msg.type}') + return False + await self.logger.error(f'Unexpected message type during auth: {msg.type}') + return False + except asyncio.TimeoutError: + await self.logger.error('Auth response timeout') + return False + + async def _heartbeat_loop(self): + """Periodically send heartbeat pings.""" + try: + while self._running and self._ws and not self._ws.closed: + await asyncio.sleep(self.heartbeat_interval) + if not self._running or not self._ws or self._ws.closed: + break + + if self._missed_pong_count >= self._max_missed_pong: + await self.logger.warning( + f'No heartbeat ack for {self._missed_pong_count} consecutive pings, connection considered dead' + ) + await self._ws.close() + break + + self._missed_pong_count += 1 + frame = { + 'cmd': CMD_HEARTBEAT, + 'headers': {'req_id': _generate_req_id(CMD_HEARTBEAT)}, + } + try: + await self._send_frame(frame) + except Exception: + break + except asyncio.CancelledError: + pass + + async def _listen_loop(self): + """Listen for incoming WebSocket frames and dispatch them.""" + async for msg in self._ws: + if not self._running: + break + if msg.type == aiohttp.WSMsgType.TEXT: + try: + frame = json.loads(msg.data) + await self._handle_frame(frame) + except json.JSONDecodeError: + await self.logger.error(f'Failed to parse WebSocket message: {str(msg.data)[:200]}') + except Exception: + await self.logger.error(f'Error handling frame: {traceback.format_exc()}') + elif msg.type == aiohttp.WSMsgType.BINARY: + try: + frame = json.loads(msg.data) + await self._handle_frame(frame) + except Exception: + await self.logger.error(f'Error handling binary frame: {traceback.format_exc()}') + elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING): + await self.logger.warning(f'WebSocket connection closed: {msg.type}') + break + + # ── Frame handling ────────────────────────────────────────────── + + async def _handle_frame(self, frame: dict): + """Route an incoming frame to the appropriate handler.""" + cmd = frame.get('cmd', '') + + # Message push + if cmd == CMD_MSG_CALLBACK: + asyncio.create_task(self._handle_message_callback(frame)) + return + + # Event push + if cmd == CMD_EVENT_CALLBACK: + asyncio.create_task(self._handle_event_callback(frame)) + return + + # No cmd → response/ACK frame, dispatch by req_id prefix + req_id = frame.get('headers', {}).get('req_id', '') + + # Check pending ACKs first + if req_id in self._pending_acks: + future = self._pending_acks.pop(req_id) + if not future.done(): + future.set_result(frame) + return + + # Heartbeat response + if req_id.startswith(CMD_HEARTBEAT): + if frame.get('errcode') == 0: + self._missed_pong_count = 0 + return + + # Unknown frame + await self.logger.warning(f'Unknown frame: {_frame_snippet(frame)}') + + async def _handle_message_callback(self, frame: dict): + """Handle an incoming message callback frame.""" + try: + body = frame.get('body', {}) + req_id = frame.get('headers', {}).get('req_id', '') + + event_type = extract_wecom_event_type(body) + if event_type == 'template_card_event': + await self._handle_template_card_event_frame(frame, body) + return + if event_type: + await self.logger.debug(f'Received msg_callback event_type={event_type}: {_frame_snippet(frame)}') + + # Parse message using shared logic + message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger) + if not message_data: + return + + # Generate stream_id for this message and store the mapping + stream_id = _generate_req_id('stream') + msg_id = message_data.get('msgid', '') + if msg_id: + self._stream_ids[msg_id] = f'{req_id}|{stream_id}' + # Store session info for feedback tracking + self._stream_sessions[msg_id] = { + 'req_id': req_id, + 'stream_id': stream_id, + 'msg_id': msg_id, + 'user_id': message_data.get('userid', ''), + 'chat_id': message_data.get('chatid', ''), + 'chat_type': message_data.get('type', 'single'), + } + message_data['stream_id'] = stream_id + message_data['req_id'] = req_id + + event = wecombotevent.WecomBotEvent(message_data) + await self._dispatch_event(event) + except Exception: + await self.logger.error(f'Error in message callback: {traceback.format_exc()}') + + async def _handle_event_callback(self, frame: dict): + """Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event).""" + try: + body = frame.get('body', {}) + req_id = frame.get('headers', {}).get('req_id', '') + + event_info = body.get('event', {}) if isinstance(body.get('event'), dict) else body + event_type = extract_wecom_event_type(body) + if not event_type: + await self.logger.warning(f'Received event_callback without event_type: {_frame_snippet(frame)}') + else: + await self.logger.debug(f'Received event_callback event_type={event_type}') + + message_data = { + 'msgtype': 'event', + 'type': body.get('chattype', 'single'), + 'event': event_info, + 'eventtype': event_type, + 'msgid': body.get('msgid', ''), + 'aibotid': body.get('aibotid', ''), + 'req_id': req_id, + } + + from_info = body.get('from', {}) + message_data['userid'] = from_info.get('userid', '') + message_data['username'] = from_info.get('alias', '') or from_info.get('userid', '') + + if body.get('chatid'): + message_data['chatid'] = body.get('chatid', '') + + if event_type == 'feedback_event': + feedback_event = event_info.get('feedback_event', {}) + feedback_id = feedback_event.get('id', '') + feedback_type = feedback_event.get('type', 0) + feedback_content = feedback_event.get('content', '') + inaccurate_reasons = feedback_event.get('inaccurate_reason_list', []) + + await self.logger.info( + f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, ' + f'content={feedback_content}, reasons={inaccurate_reasons}' + ) + + # Look up session by feedback_id + session_info = self._feedback_sessions.get(feedback_id) + session = None + if session_info: + session = StreamSession( + stream_id=session_info.get('stream_id', ''), + msg_id=session_info.get('msg_id', ''), + chat_id=session_info.get('chat_id') or None, + user_id=session_info.get('user_id') or None, + feedback_id=feedback_id, + ) + await self.logger.info( + f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}' + ) + else: + await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话') + + for handler in self._message_handlers.get('feedback', []): + try: + await handler( + feedback_id=feedback_id, + feedback_type=feedback_type, + feedback_content=feedback_content, + inaccurate_reasons=inaccurate_reasons, + session=session, + ) + except Exception: + await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}') + return + + if event_type == 'template_card_event': + await self._handle_template_card_event_frame(frame, body) + return + + event = wecombotevent.WecomBotEvent(message_data) + + if event_type in self._message_handlers: + for handler in self._message_handlers[event_type]: + await handler(event) + + if 'event' in self._message_handlers: + for handler in self._message_handlers['event']: + await handler(event) + + except Exception: + await self.logger.error(f'Error in event callback: {traceback.format_exc()}') + + async def _handle_template_card_event_frame(self, frame: dict, body: dict): + """Handle template_card_event frames from event_callback or msg_callback.""" + tce = extract_template_card_event_payload(body) + task_id, event_key, card_type = extract_template_card_action(tce) + await self.logger.info( + f'Received template_card_event (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}' + ) + + pending = self._pending_forms_by_task.get(task_id) + if pending is None: + await self.logger.warning(f'No pending_form found for task_id={task_id} (ws); card event ignored') + return + + req_id_for_update = frame.get('headers', {}).get('req_id', '') + form_data = pending.get('form_data', {}) or {} + selections = extract_template_card_selections(tce, form_data) + if not selections: + selections = parse_select_button_action(event_key, form_data) + if card_type == 'multiple_interaction' and not selections: + await self.logger.warning( + f'multiple_interaction callback has no parseable selections (ws): raw={str(tce)[:1000]}' + ) + self._drop_pending_form_task(task_id, pending) + return + + update_card = build_button_interaction_update_card( + form_data, + task_id, + event_key, + source=self.card_source, + ) + if card_type == 'multiple_interaction' or selections: + update_card = build_multiple_interaction_update_card( + form_data, + task_id, + selections, + source=self.card_source, + ) + try: + await self.update_template_card(req_id_for_update, update_card) + except Exception: + await self.logger.warning(f'Failed to update template card (ws): {traceback.format_exc()}') + + if self._card_action_callback is not None: + try: + session = StreamSession( + stream_id=pending.get('stream_id', ''), + msg_id=pending.get('msg_id', ''), + chat_id=pending.get('chat_id') or None, + user_id=pending.get('user_id') or None, + ) + session.pending_form = pending.get('form_data') + session.pending_form_task_id = task_id + await self._card_action_callback(session, event_key, task_id, body) + except Exception: + await self.logger.error(f'card action callback raised (ws): {traceback.format_exc()}') + + self._drop_pending_form_task(task_id, pending) + + def _drop_pending_form_task(self, task_id: str, pending: dict) -> None: + self._pending_forms_by_task.pop(task_id, None) + msg_id = pending.get('msg_id', '') + if msg_id: + self._task_id_by_msg.pop(msg_id, None) + self._stream_sessions.pop(msg_id, None) + + async def _dispatch_event(self, event: wecombotevent.WecomBotEvent): + """Dispatch a message event to registered handlers with deduplication.""" + try: + message_id = event.message_id + if message_id in self._msg_id_map: + self._msg_id_map[message_id] += 1 + return + self._msg_id_map[message_id] = 1 + + msg_type = event.type + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + except Exception: + await self.logger.error(f'Error dispatching event: {traceback.format_exc()}') + + # ── Reply sending with serial queue ───────────────────────────── + + async def _send_reply( + self, + req_id: str, + body: dict, + cmd: str = CMD_RESPOND_MSG, + ) -> Optional[dict]: + """Send a reply frame and wait for ACK. + + Replies with the same req_id are serialized to maintain ordering. + """ + if not self._ws or self._ws.closed: + return None + + frame = { + 'cmd': cmd, + 'headers': {'req_id': req_id}, + 'body': body, + } + + # Ensure serial delivery per req_id + if req_id not in self._reply_queues: + self._reply_queues[req_id] = asyncio.Queue() + self._reply_workers[req_id] = asyncio.create_task(self._reply_queue_worker(req_id)) + + future: asyncio.Future = asyncio.get_event_loop().create_future() + await self._reply_queues[req_id].put((frame, future)) + return await future + + async def _reply_queue_worker(self, req_id: str): + """Process reply queue items serially for a given req_id.""" + queue = self._reply_queues[req_id] + try: + while self._running: + try: + frame, future = await asyncio.wait_for(queue.get(), timeout=60.0) + except asyncio.TimeoutError: + # Queue idle, clean up worker + break + + try: + ack = await self._send_and_wait_ack(frame) + if not future.done(): + future.set_result(ack) + except Exception as e: + if not future.done(): + future.set_exception(e) + except asyncio.CancelledError: + pass + finally: + self._reply_queues.pop(req_id, None) + self._reply_workers.pop(req_id, None) + + async def _send_and_wait_ack(self, frame: dict) -> Optional[dict]: + """Send a frame and wait for the corresponding ACK.""" + req_id = frame['headers']['req_id'] + ack_future: asyncio.Future = asyncio.get_event_loop().create_future() + self._pending_acks[req_id] = ack_future + + try: + await self._send_frame(frame) + result = await asyncio.wait_for(ack_future, timeout=self._reply_ack_timeout) + if result.get('errcode', 0) != 0: + await self.logger.warning( + f'Reply ACK error: errcode={result.get("errcode")}, errmsg={result.get("errmsg")}' + ) + return result + except asyncio.TimeoutError: + self._pending_acks.pop(req_id, None) + await self.logger.warning(f'Reply ACK timeout ({self._reply_ack_timeout}s) for req_id={req_id}') + return None + + async def _send_frame(self, frame: dict): + """Send a JSON frame over the WebSocket connection.""" + if self._ws and not self._ws.closed: + await self._ws.send_str(json.dumps(frame, ensure_ascii=False)) + + def _clear_pending_acks(self, reason: str): + """Reject all pending ACK futures on disconnection.""" + for req_id, future in self._pending_acks.items(): + if not future.done(): + future.set_exception(ConnectionError(reason)) + self._pending_acks.clear() diff --git a/src/langbot/libs/wecom_api/WXBizMsgCrypt3.py b/src/langbot/libs/wecom_api/WXBizMsgCrypt3.py new file mode 100644 index 0000000..ceb5e71 --- /dev/null +++ b/src/langbot/libs/wecom_api/WXBizMsgCrypt3.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python +# -*- encoding:utf-8 -*- + +"""对企业微信发送给企业后台的消息加解密示例代码. +@copyright: Copyright (c) 1998-2014 Tencent Inc. + +""" + +# ------------------------------------------------------------------------ +import logging +import base64 +import random +import hashlib +import time +import struct +from Crypto.Cipher import AES +import xml.etree.cElementTree as ET +import socket + +from . import ierror + + +""" +Crypto.Cipher包已不再维护,开发者可以通过以下命令下载安装最新版的加解密工具包 + pip install pycryptodome +""" + + +class FormatException(Exception): + pass + + +def throw_exception(message, exception_class=FormatException): + """my define raise exception function""" + raise exception_class(message) + + +class SHA1: + """计算企业微信的消息签名接口""" + + def getSHA1(self, token, timestamp, nonce, encrypt): + """用SHA1算法生成安全签名 + @param token: 票据 + @param timestamp: 时间戳 + @param encrypt: 密文 + @param nonce: 随机字符串 + @return: 安全签名 + """ + try: + sortlist = [token, timestamp, nonce, encrypt] + sortlist.sort() + sha = hashlib.sha1() + sha.update(''.join(sortlist).encode()) + return ierror.WXBizMsgCrypt_OK, sha.hexdigest() + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_ComputeSignature_Error, None + + +class XMLParse: + """提供提取消息格式中的密文及生成回复消息格式的接口""" + + # xml消息模板 + AES_TEXT_RESPONSE_TEMPLATE = """ + + +%(timestamp)s + +""" + + def extract(self, xmltext): + """提取出xml数据包中的加密消息 + @param xmltext: 待提取的xml字符串 + @return: 提取出的加密消息字符串 + """ + try: + xml_tree = ET.fromstring(xmltext) + encrypt = xml_tree.find('Encrypt') + return ierror.WXBizMsgCrypt_OK, encrypt.text + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_ParseXml_Error, None + + def generate(self, encrypt, signature, timestamp, nonce): + """生成xml消息 + @param encrypt: 加密后的消息密文 + @param signature: 安全签名 + @param timestamp: 时间戳 + @param nonce: 随机字符串 + @return: 生成的xml字符串 + """ + resp_dict = { + 'msg_encrypt': encrypt, + 'msg_signaturet': signature, + 'timestamp': timestamp, + 'nonce': nonce, + } + resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict + return resp_xml + + +class PKCS7Encoder: + """提供基于PKCS7算法的加解密接口""" + + block_size = 32 + + def encode(self, text): + """对需要加密的明文进行填充补位 + @param text: 需要进行填充补位操作的明文 + @return: 补齐明文字符串 + """ + text_length = len(text) + # 计算需要填充的位数 + amount_to_pad = self.block_size - (text_length % self.block_size) + if amount_to_pad == 0: + amount_to_pad = self.block_size + # 获得补位所用的字符 + pad = chr(amount_to_pad) + return text + (pad * amount_to_pad).encode() + + def decode(self, decrypted): + """删除解密后明文的补位字符 + @param decrypted: 解密后的明文 + @return: 删除补位字符后的明文 + """ + pad = ord(decrypted[-1]) + if pad < 1 or pad > 32: + pad = 0 + return decrypted[:-pad] + + +class Prpcrypt(object): + """提供接收和推送给企业微信消息的加解密接口""" + + def __init__(self, key): + # self.key = base64.b64decode(key+"=") + self.key = key + # 设置加解密模式为AES的CBC模式 + self.mode = AES.MODE_CBC + + def encrypt(self, text, receiveid): + """对明文进行加密 + @param text: 需要加密的明文 + @return: 加密得到的字符串 + """ + # 16位随机字符串添加到明文开头 + text = text.encode() + text = self.get_random_str() + struct.pack('I', socket.htonl(len(text))) + text + receiveid.encode() + + # 使用自定义的填充方式对明文进行补位填充 + pkcs7 = PKCS7Encoder() + text = pkcs7.encode(text) + # 加密 + cryptor = AES.new(self.key, self.mode, self.key[:16]) + try: + ciphertext = cryptor.encrypt(text) + # 使用BASE64对加密后的字符串进行编码 + return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext) + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_EncryptAES_Error, None + + def decrypt(self, text, receiveid): + """对解密后的明文进行补位删除 + @param text: 密文 + @return: 删除填充补位后的明文 + """ + try: + cryptor = AES.new(self.key, self.mode, self.key[:16]) + # 使用BASE64对密文进行解码,然后AES-CBC解密 + plain_text = cryptor.decrypt(base64.b64decode(text)) + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_DecryptAES_Error, None + try: + pad = plain_text[-1] + # 去掉补位字符串 + # pkcs7 = PKCS7Encoder() + # plain_text = pkcs7.encode(plain_text) + # 去除16位随机字符串 + content = plain_text[16:-pad] + xml_len = socket.ntohl(struct.unpack('I', content[:4])[0]) + xml_content = content[4 : xml_len + 4] + from_receiveid = content[xml_len + 4 :] + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_IllegalBuffer, None + + if from_receiveid.decode('utf8') != receiveid: + return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None + return 0, xml_content + + def get_random_str(self): + """随机生成16位字符串 + @return: 16位字符串 + """ + return str(random.randint(1000000000000000, 9999999999999999)).encode() + + +class WXBizMsgCrypt(object): + # 构造函数 + def __init__(self, sToken, sEncodingAESKey, sReceiveId): + try: + self.key = base64.b64decode(sEncodingAESKey + '=') + assert len(self.key) == 32 + except Exception: + throw_exception('[error]: EncodingAESKey unvalid !', FormatException) + # return ierror.WXBizMsgCrypt_IllegalAesKey,None + self.m_sToken = sToken + self.m_sReceiveId = sReceiveId + + # 验证URL + # @param sMsgSignature: 签名串,对应URL参数的msg_signature + # @param sTimeStamp: 时间戳,对应URL参数的timestamp + # @param sNonce: 随机串,对应URL参数的nonce + # @param sEchoStr: 随机串,对应URL参数的echostr + # @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效 + # @return:成功0,失败返回对应的错误码 + + def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr): + sha1 = SHA1() + ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr) + if ret != 0: + return ret, None + if not signature == sMsgSignature: + return ierror.WXBizMsgCrypt_ValidateSignature_Error, None + pc = Prpcrypt(self.key) + ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId) + return ret, sReplyEchoStr + + def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None): + # 将企业回复用户的消息加密打包 + # @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串 + # @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间 + # @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce + # sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串, + # return:成功0,sEncryptMsg,失败返回对应的错误码None + pc = Prpcrypt(self.key) + ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId) + encrypt = encrypt.decode('utf8') + if ret != 0: + return ret, None + if timestamp is None: + timestamp = str(int(time.time())) + # 生成安全签名 + sha1 = SHA1() + ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt) + if ret != 0: + return ret, None + xmlParse = XMLParse() + return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce) + + def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce): + # 检验消息的真实性,并且获取解密后的明文 + # @param sMsgSignature: 签名串,对应URL参数的msg_signature + # @param sTimeStamp: 时间戳,对应URL参数的timestamp + # @param sNonce: 随机串,对应URL参数的nonce + # @param sPostData: 密文,对应POST请求的数据 + # xml_content: 解密后的原文,当return返回0时有效 + # @return: 成功0,失败返回对应的错误码 + # 验证安全签名 + xmlParse = XMLParse() + ret, encrypt = xmlParse.extract(sPostData) + if ret != 0: + return ret, None + sha1 = SHA1() + ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt) + if ret != 0: + return ret, None + if not signature == sMsgSignature: + return ierror.WXBizMsgCrypt_ValidateSignature_Error, None + pc = Prpcrypt(self.key) + ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId) + return ret, xml_content diff --git a/src/langbot/libs/wecom_api/__init__.py b/src/langbot/libs/wecom_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/libs/wecom_api/api.py b/src/langbot/libs/wecom_api/api.py new file mode 100644 index 0000000..b6c7ef0 --- /dev/null +++ b/src/langbot/libs/wecom_api/api.py @@ -0,0 +1,553 @@ +from quart import request +from .WXBizMsgCrypt3 import WXBizMsgCrypt +import base64 +import binascii +import httpx +import traceback +from urllib.parse import quote +from quart import Quart +import xml.etree.ElementTree as ET +from typing import Callable, Dict, Any +from .wecomevent import WecomEvent +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import aiofiles + + +class WecomClient: + def __init__( + self, + corpid: str, + secret: str, + token: str, + EncodingAESKey: str, + contacts_secret: str, + logger: None, + unified_mode: bool = False, + api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin', + ): + self.corpid = corpid + self.secret = secret + self.access_token_for_contacts = '' + self.token = token + self.aes = EncodingAESKey + self.base_url = api_base_url + self.access_token = '' + self.secret_for_contacts = contacts_secret + self.logger = logger + self.unified_mode = unified_mode + self.app = Quart(__name__) + + # 只有在非统一模式下才注册独立路由 + if not self.unified_mode: + self.app.add_url_rule( + '/callback/command', + 'handle_callback', + self.handle_callback_request, + methods=['GET', 'POST'], + ) + + self._message_handlers = { + 'example': [], + } + + # access——token操作 + async def check_access_token(self): + return bool(self.access_token and self.access_token.strip()) + + async def check_access_token_for_contacts(self): + return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip()) + + async def get_access_token(self, secret): + url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}' + async with httpx.AsyncClient() as client: + response = await client.get(url) + data = response.json() + if 'access_token' in data: + return data['access_token'] + else: + await self.logger.error(f'获取accesstoken失败:{response.json()}') + raise Exception(f'未获取access token: {data}') + + async def get_user_info(self, userid: str) -> dict: + """ + Get user information by user ID using the application secret. + + Args: + userid: The user ID to look up. + + Returns: + dict: User information including 'name' field. + """ + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid) + async with httpx.AsyncClient() as client: + response = await client.get(url) + data = response.json() + if data.get('errcode') == 40014 or data.get('errcode') == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.get_user_info(userid) + if data.get('errcode', 0) != 0: + await self.logger.error(f'获取用户信息失败:{data}') + return {} + return data + + async def get_users(self): + if not self.check_access_token_for_contacts(): + self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts) + + url = self.base_url + '/user/list_id?access_token=' + self.access_token_for_contacts + async with httpx.AsyncClient() as client: + params = { + 'cursor': '', + 'limit': 10000, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] == 0: + dept_users = data['dept_user'] + userid = [] + for user in dept_users: + userid.append(user['userid']) + return userid + else: + raise Exception('未获取用户') + + async def send_to_all(self, content: str, agent_id: int): + if not self.check_access_token_for_contacts(): + self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts) + + url = self.base_url + '/message/send?access_token=' + self.access_token_for_contacts + user_ids = await self.get_users() + user_ids_string = '|'.join(user_ids) + async with httpx.AsyncClient() as client: + params = { + 'touser': user_ids_string, + 'msgtype': 'text', + 'agentid': agent_id, + 'text': { + 'content': content, + }, + 'safe': 0, + 'enable_id_trans': 0, + 'enable_duplicate_check': 0, + 'duplicate_check_interval': 1800, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] != 0: + raise Exception('Failed to send message: ' + str(data)) + + async def send_image(self, user_id: str, agent_id: int, media_id: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = self.base_url + '/message/send?access_token=' + self.access_token + async with httpx.AsyncClient() as client: + params = { + 'touser': user_id, + 'msgtype': 'image', + 'agentid': agent_id, + 'image': { + 'media_id': media_id, + }, + 'safe': 0, + 'enable_id_trans': 0, + 'enable_duplicate_check': 0, + 'duplicate_check_interval': 1800, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_image(user_id, agent_id, media_id) + if data['errcode'] != 0: + await self.logger.error(f'发送图片失败:{data}') + raise Exception('Failed to send image: ' + str(data)) + + async def send_voice(self, user_id: str, agent_id: int, media_id: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url + '/message/send?access_token=' + self.access_token + async with httpx.AsyncClient() as client: + params = { + 'touser': user_id, + 'msgtype': 'voice', + 'agentid': agent_id, + 'voice': { + 'media_id': media_id, + }, + 'safe': 0, + 'enable_id_trans': 0, + 'enable_duplicate_check': 0, + 'duplicate_check_interval': 1800, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_voice(user_id, agent_id, media_id) + if data['errcode'] != 0: + await self.logger.error(f'发送语音失败:{data}') + raise Exception('Failed to send voice: ' + str(data)) + + async def send_file(self, user_id: str, agent_id: int, media_id: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url + '/message/send?access_token=' + self.access_token + async with httpx.AsyncClient() as client: + params = { + 'touser': user_id, + 'msgtype': 'file', + 'agentid': agent_id, + 'file': { + 'media_id': media_id, + }, + 'safe': 0, + 'enable_id_trans': 0, + 'enable_duplicate_check': 0, + 'duplicate_check_interval': 1800, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_file(user_id, agent_id, media_id) + if data['errcode'] != 0: + await self.logger.error(f'发送文件失败:{data}') + raise Exception('Failed to send file: ' + str(data)) + + async def send_private_msg(self, user_id: str, agent_id: int, content: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = self.base_url + '/message/send?access_token=' + self.access_token + async with httpx.AsyncClient(timeout=None) as client: + params = { + 'touser': user_id, + 'msgtype': 'text', + 'agentid': agent_id, + 'text': { + 'content': content, + }, + 'safe': 0, + 'enable_id_trans': 0, + 'enable_duplicate_check': 0, + 'duplicate_check_interval': 1800, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_private_msg(user_id, agent_id, content) + if data['errcode'] != 0: + await self.logger.error(f'发送消息失败:{data}') + raise Exception('Failed to send message: ' + str(data)) + + async def handle_callback_request(self): + """处理回调请求(独立端口模式,使用全局 request)。""" + return await self._handle_callback_internal(request) + + async def handle_unified_webhook(self, req): + """处理回调请求(统一 webhook 模式,显式传递 request)。 + + Args: + req: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """ + 处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 + + Args: + req: Quart Request 对象 + """ + try: + msg_signature = req.args.get('msg_signature') + timestamp = req.args.get('timestamp') + nonce = req.args.get('nonce') + + wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) + if req.method == 'GET': + echostr = req.args.get('echostr') + ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + if ret != 0: + await self.logger.error('验证失败') + raise Exception(f'验证失败,错误码: {ret}') + return reply_echo_str + + elif req.method == 'POST': + encrypt_msg = await req.data + ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) + if ret != 0: + await self.logger.error('消息解密失败') + raise Exception(f'消息解密失败,错误码: {ret}') + + # 解析消息并处理 + message_data = await self.get_message(xml_msg) + if message_data: + event = WecomEvent.from_payload(message_data) # 转换为 WecomEvent 对象 + if event: + await self._handle_message(event) + + return 'success' + except Exception as e: + await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') + return f'Error processing request: {str(e)}', 400 + + async def run_task(self, host: str, port: int, *args, **kwargs): + """ + 启动 Quart 应用。 + """ + await self.app.run_task(host=host, port=port, *args, **kwargs) + + def on_message(self, msg_type: str): + """ + 注册消息类型处理器。 + """ + + def decorator(func: Callable[[WecomEvent], None]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + + return decorator + + async def _handle_message(self, event: WecomEvent): + """ + 处理消息事件。 + """ + msg_type = event.type + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + + async def get_message(self, xml_msg: str) -> Dict[str, Any]: + """ + 解析微信返回的 XML 消息并转换为字典。 + """ + root = ET.fromstring(xml_msg) + message_data = { + 'ToUserName': root.find('ToUserName').text, + 'FromUserName': root.find('FromUserName').text, + 'CreateTime': int(root.find('CreateTime').text), + 'MsgType': root.find('MsgType').text, + 'Content': root.find('Content').text if root.find('Content') is not None else None, + 'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None, + 'AgentID': int(root.find('AgentID').text) if root.find('AgentID') is not None else None, + } + if message_data['MsgType'] == 'image': + message_data['MediaId'] = root.find('MediaId').text if root.find('MediaId') is not None else None + message_data['PicUrl'] = root.find('PicUrl').text if root.find('PicUrl') is not None else None + + return message_data + + @staticmethod + async def get_image_type(image_bytes: bytes) -> str: + """ + 通过图片的magic numbers判断图片类型 + """ + magic_numbers = { + b'\xff\xd8\xff': 'jpg', + b'\x89\x50\x4e\x47': 'png', + b'\x47\x49\x46': 'gif', + b'\x42\x4d': 'bmp', + b'\x00\x00\x01\x00': 'ico', + } + + for magic, ext in magic_numbers.items(): + if image_bytes.startswith(magic): + return ext + return 'jpg' # 默认返回jpg + + async def upload_image_to_work(self, image: platform_message.Image): + """ + 获取 media_id + """ + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' + file_bytes = None + file_name = 'uploaded_file.txt' + + # 获取文件的二进制数据 + if image.path: + async with aiofiles.open(image.path, 'rb') as f: + file_bytes = await f.read() + file_name = image.path.split('/')[-1] + elif image.url: + file_bytes = await self.download_media_to_bytes(image.url) + file_name = image.url.split('/')[-1] + elif image.base64: + try: + base64_data = image.base64 + if ',' in base64_data: + base64_data = base64_data.split(',', 1)[1] + padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 + padded_base64 = base64_data + '=' * padding + file_bytes = base64.b64decode(padded_base64) + except binascii.Error as e: + raise ValueError(f'Invalid base64 string: {str(e)}') + else: + await self.logger.error('Image对象出错') + raise ValueError('image对象出错') + + # 设置 multipart/form-data 格式的文件 + boundary = '-------------------------acebdf13572468' + headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'} + body = ( + ( + f'--{boundary}\r\n' + f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n' + f'Content-Type: application/octet-stream\r\n\r\n' + ).encode('utf-8') + + file_bytes + + f'\r\n--{boundary}--\r\n'.encode('utf-8') + ) + + # 上传文件 + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, content=body) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + media_id = await self.upload_image_to_work(image) + if data.get('errcode', 0) != 0: + await self.logger.error(f'上传图片失败:{data}') + raise Exception('failed to upload file') + + media_id = data.get('media_id') + return media_id + + async def upload_voice_to_work(self, voice: platform_message.Voice): + """ + 上传语音文件到企业微信 + """ + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' + file_bytes = None + file_name = 'voice.mp3' + + if voice.path: + async with aiofiles.open(voice.path, 'rb') as f: + file_bytes = await f.read() + file_name = voice.path.split('/')[-1] + elif voice.url: + file_bytes = await self.download_media_to_bytes(voice.url) + file_name = voice.url.split('/')[-1] + elif voice.base64: + try: + base64_data = voice.base64 + if ',' in base64_data: + base64_data = base64_data.split(',', 1)[1] + padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 + padded_base64 = base64_data + '=' * padding + file_bytes = base64.b64decode(padded_base64) + except binascii.Error as e: + raise ValueError(f'Invalid base64 string: {str(e)}') + else: + await self.logger.error('Voice对象出错') + raise ValueError('voice对象出错') + + boundary = '-------------------------acebdf13572468' + headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'} + body = ( + ( + f'--{boundary}\r\n' + f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n' + f'Content-Type: application/octet-stream\r\n\r\n' + ).encode('utf-8') + + file_bytes + + f'\r\n--{boundary}--\r\n'.encode('utf-8') + ) + + # print(body) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, content=body) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + media_id = await self.upload_voice_to_work(voice) + if data.get('errcode', 0) != 0: + await self.logger.error(f'上传语音文件失败:{data}') + raise Exception('failed to upload file') + media_id = data.get('media_id') + return media_id + + async def upload_file_to_work(self, file: platform_message.File): + """ + 上传文件到企业微信 + """ + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' + file_bytes = None + file_name = 'file.txt' + if file.path: + async with aiofiles.open(file.path, 'rb') as f: + file_bytes = await f.read() + file_name = file.path.split('/')[-1] + elif file.url: + file_bytes = await self.download_media_to_bytes(file.url) + file_name = file.url.split('/')[-1] + elif file.base64: + try: + base64_data = file.base64 + if ',' in base64_data: + base64_data = base64_data.split(',', 1)[1] + padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 + padded_base64 = base64_data + '=' * padding + file_bytes = base64.b64decode(padded_base64) + except binascii.Error as e: + raise ValueError(f'Invalid base64 string: {str(e)}') + else: + await self.logger.error('File对象出错') + raise ValueError('file对象出错') + boundary = '-------------------------acebdf13572468' + headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'} + body = ( + ( + f'--{boundary}\r\n' + f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n' + f'Content-Type: application/octet-stream\r\n\r\n' + ).encode('utf-8') + + file_bytes + + f'\r\n--{boundary}--\r\n'.encode('utf-8') + ) + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, content=body) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + media_id = await self.upload_file_to_work(file) + if data.get('errcode', 0) != 0: + await self.logger.error(f'上传文件失败:{data}') + raise Exception('failed to upload file') + media_id = data.get('media_id') + return media_id + + async def download_media_to_bytes(self, url: str) -> bytes: + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + return response.content + + # 进行media_id的获取 + async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File): + if isinstance(media, platform_message.Image): + media_id = await self.upload_image_to_work(image=media) + elif isinstance(media, platform_message.Voice): + media_id = await self.upload_voice_to_work(voice=media) + elif isinstance(media, platform_message.File): + media_id = await self.upload_file_to_work(file=media) + else: + raise ValueError('Unsupported media type') + return media_id diff --git a/src/langbot/libs/wecom_api/ierror.py b/src/langbot/libs/wecom_api/ierror.py new file mode 100644 index 0000000..6c7ca12 --- /dev/null +++ b/src/langbot/libs/wecom_api/ierror.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +######################################################################### +# Author: jonyqin +# Created Time: Thu 11 Sep 2014 01:53:58 PM CST +# File Name: ierror.py +# Description:定义错误码含义 +######################################################################### +WXBizMsgCrypt_OK = 0 +WXBizMsgCrypt_ValidateSignature_Error = -40001 +WXBizMsgCrypt_ParseXml_Error = -40002 +WXBizMsgCrypt_ComputeSignature_Error = -40003 +WXBizMsgCrypt_IllegalAesKey = -40004 +WXBizMsgCrypt_ValidateCorpid_Error = -40005 +WXBizMsgCrypt_EncryptAES_Error = -40006 +WXBizMsgCrypt_DecryptAES_Error = -40007 +WXBizMsgCrypt_IllegalBuffer = -40008 +WXBizMsgCrypt_EncodeBase64_Error = -40009 +WXBizMsgCrypt_DecodeBase64_Error = -40010 +WXBizMsgCrypt_GenReturnXml_Error = -40011 diff --git a/src/langbot/libs/wecom_api/wecomevent.py b/src/langbot/libs/wecom_api/wecomevent.py new file mode 100644 index 0000000..a0c2c7d --- /dev/null +++ b/src/langbot/libs/wecom_api/wecomevent.py @@ -0,0 +1,179 @@ +from typing import Dict, Any, Optional + + +class WecomEvent(dict): + """ + 封装从企业微信收到的事件数据对象(字典),提供属性以获取其中的字段。 + + 除 `type` 和 `detail_type` 属性对于任何事件都有效外,其它属性是否存在(若不存在则返回 `None`)依事件类型不同而不同。 + """ + + @staticmethod + def from_payload(payload: Dict[str, Any]) -> Optional['WecomEvent']: + """ + 从企业微信事件数据构造 `WecomEvent` 对象。 + + Args: + payload (Dict[str, Any]): 解密后的企业微信事件数据。 + + Returns: + Optional[WecomEvent]: 如果事件数据合法,则返回 WecomEvent 对象;否则返回 None。 + """ + try: + event = WecomEvent(payload) + _ = event.type, event.detail_type # 确保必须字段存在 + return event + except KeyError: + return None + + @property + def type(self) -> str: + """ + 事件类型,例如 "message"、"event"、"text" 等。 + + Returns: + str: 事件类型。 + """ + return self.get('MsgType', '') + + @property + def picurl(self) -> str: + """ + 图片链接 + """ + return self.get('PicUrl') + + @property + def detail_type(self) -> str: + """ + 事件详细类型,依 `type` 的不同而不同。例如: + - 消息事件: "text", "image", "voice", 等 + - 事件通知: "subscribe", "unsubscribe", "click", 等 + + Returns: + str: 事件详细类型。 + """ + if self.type == 'event': + return self.get('Event', '') + return self.type + + @property + def name(self) -> str: + """ + 事件名,对于消息事件是 `type.detail_type`,对于其他事件是 `event_type`。 + + Returns: + str: 事件名。 + """ + return f'{self.type}.{self.detail_type}' + + @property + def user_id(self) -> Optional[str]: + """ + 用户 ID,例如消息的发送者或事件的触发者。 + + Returns: + Optional[str]: 用户 ID。 + """ + return self.get('FromUserName') + + @property + def agent_id(self) -> Optional[int]: + """ + 机器人 ID,仅在消息类型事件中存在。 + + Returns: + Optional[int]: 机器人 ID。 + """ + return self.get('AgentID') + + @property + def receiver_id(self) -> Optional[str]: + """ + 接收者 ID,例如机器人自身的企业微信 ID。 + + Returns: + Optional[str]: 接收者 ID。 + """ + return self.get('ToUserName') + + @property + def message_id(self) -> Optional[str]: + """ + 消息 ID,仅在消息类型事件中存在。 + + Returns: + Optional[str]: 消息 ID。 + """ + return self.get('MsgId') + + @property + def message(self) -> Optional[str]: + """ + 消息内容,仅在消息类型事件中存在。 + + Returns: + Optional[str]: 消息内容。 + """ + return self.get('Content') + + @property + def media_id(self) -> Optional[str]: + """ + 媒体文件 ID,仅在图片、语音等消息类型中存在。 + + Returns: + Optional[str]: 媒体文件 ID。 + """ + return self.get('MediaId') + + @property + def timestamp(self) -> Optional[int]: + """ + 事件发生的时间戳。 + + Returns: + Optional[int]: 时间戳。 + """ + return self.get('CreateTime') + + @property + def event_key(self) -> Optional[str]: + """ + 事件的 Key 值,例如点击菜单时的 `EventKey`。 + + Returns: + Optional[str]: 事件 Key。 + """ + return self.get('EventKey') + + def __getattr__(self, key: str) -> Optional[Any]: + """ + 允许通过属性访问数据中的任意字段。 + + Args: + key (str): 字段名。 + + Returns: + Optional[Any]: 字段值。 + """ + return self.get(key) + + def __setattr__(self, key: str, value: Any) -> None: + """ + 允许通过属性设置数据中的任意字段。 + + Args: + key (str): 字段名。 + value (Any): 字段值。 + """ + self[key] = value + + def __repr__(self) -> str: + """ + 生成事件对象的字符串表示。 + + Returns: + str: 字符串表示。 + """ + return f'' diff --git a/src/langbot/libs/wecom_customer_service_api/__init__.py b/src/langbot/libs/wecom_customer_service_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py new file mode 100644 index 0000000..70270b7 --- /dev/null +++ b/src/langbot/libs/wecom_customer_service_api/api.py @@ -0,0 +1,435 @@ +from quart import request +from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt +import base64 +import binascii +import httpx +import traceback +from quart import Quart +import xml.etree.ElementTree as ET +from typing import Callable +from .wecomcsevent import WecomCSEvent +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import aiofiles +import time + + +class WecomCSClient: + def __init__( + self, + corpid: str, + secret: str, + token: str, + EncodingAESKey: str, + logger: None, + unified_mode: bool = False, + api_base_url: str = 'https://qyapi.weixin.qq.com/cgi-bin', + ): + self.corpid = corpid + self.secret = secret + self.access_token_for_contacts = '' + self.token = token + self.aes = EncodingAESKey + self.base_url = api_base_url + self.access_token = '' + self.logger = logger + self.unified_mode = unified_mode + self.app = Quart(__name__) + + # Customer info cache: {external_userid: (info_dict, timestamp)} + self._customer_cache: dict[str, tuple[dict, float]] = {} + self._cache_ttl = 60 # Cache TTL in seconds (1 minute) + + # 只有在非统一模式下才注册独立路由 + if not self.unified_mode: + self.app.add_url_rule( + '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'] + ) + + self._message_handlers = { + 'example': [], + } + + async def get_pic_url(self, media_id: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = f'{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}' + + async with httpx.AsyncClient() as client: + response = await client.get(url) + if response.headers.get('Content-Type', '').startswith('application/json'): + data = response.json() + if data.get('errcode') in [40014, 42001]: + self.access_token = await self.get_access_token(self.secret) + return await self.get_pic_url(media_id) + else: + raise Exception('Failed to get image: ' + str(data)) + + # 否则是图片,转成 base64 + image_bytes = response.content + content_type = response.headers.get('Content-Type', '') + base64_str = base64.b64encode(image_bytes).decode('utf-8') + base64_str = f'data:{content_type};base64,{base64_str}' + return base64_str + + # access——token操作 + async def check_access_token(self): + return bool(self.access_token and self.access_token.strip()) + + async def check_access_token_for_contacts(self): + return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip()) + + async def get_access_token(self, secret): + url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}' + async with httpx.AsyncClient() as client: + response = await client.get(url) + data = response.json() + if 'access_token' in data: + return data['access_token'] + else: + raise Exception(f'未获取access token: {data}') + + async def get_detailed_message_list(self, xml_msg: str): + # 在本方法中解析消息,并且获得消息的具体内容 + if isinstance(xml_msg, bytes): + xml_msg = xml_msg.decode('utf-8') + root = ET.fromstring(xml_msg) + token = root.find('Token').text + open_kfid = root.find('OpenKfId').text + + # if open_kfid in self.openkfid_list: + # return None + # else: + # self.openkfid_list.append(open_kfid) + + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = self.base_url + '/kf/sync_msg?access_token=' + self.access_token + async with httpx.AsyncClient() as client: + params = { + 'token': token, + 'voice_format': 0, + 'open_kfid': open_kfid, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.get_detailed_message_list(xml_msg) + if data['errcode'] != 0: + raise Exception('Failed to get message') + + last_msg_data = data['msg_list'][-1] + open_kfid = last_msg_data.get('open_kfid') + # 进行获取图片操作 + if last_msg_data.get('msgtype') == 'image': + media_id = last_msg_data.get('image').get('media_id') + picurl = await self.get_pic_url(media_id) + last_msg_data['picurl'] = picurl + # await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer) + return last_msg_data + + async def change_service_status(self, userid: str, openkfid: str, servicer: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url + '/kf/service_state/get?access_token=' + self.access_token + async with httpx.AsyncClient() as client: + params = { + 'open_kfid': openkfid, + 'external_userid': userid, + 'service_state': 1, + 'servicer_userid': servicer, + } + response = await client.post(url, json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.change_service_status(userid, openkfid) + if data['errcode'] != 0: + raise Exception('Failed to change service status: ' + str(data)) + + async def send_image(self, user_id: str, agent_id: int, media_id: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url + '/media/upload?access_token=' + self.access_token + async with httpx.AsyncClient() as client: + params = { + 'touser': user_id, + 'toparty': '', + 'totag': '', + 'agentid': agent_id, + 'msgtype': 'image', + 'image': { + 'media_id': media_id, + }, + 'safe': 0, + 'enable_id_trans': 0, + 'enable_duplicate_check': 0, + 'duplicate_check_interval': 1800, + } + try: + response = await client.post(url, json=params) + data = response.json() + except Exception as e: + raise Exception('Failed to send image: ' + str(e)) + + # 企业微信错误码40014和42001,代表accesstoken问题 + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_image(user_id, agent_id, media_id) + + if data['errcode'] != 0: + raise Exception('Failed to send image: ' + str(data)) + + async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, content: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}' + + payload = { + 'touser': external_userid, + 'open_kfid': open_kfid, + 'msgid': msgid, + 'msgtype': 'text', + 'text': { + 'content': content, + }, + } + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload) + + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_text_msg(open_kfid, external_userid, msgid, content) + if data['errcode'] != 0: + await self.logger.error(f'发送消息失败:{data}') + raise Exception('Failed to send message') + return data + + async def handle_callback_request(self): + """处理回调请求(独立端口模式,使用全局 request)。""" + return await self._handle_callback_internal(request) + + async def handle_unified_webhook(self, req): + """处理回调请求(统一 webhook 模式,显式传递 request)。 + + Args: + req: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """ + 处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 + + Args: + req: Quart Request 对象 + """ + try: + msg_signature = req.args.get('msg_signature') + timestamp = req.args.get('timestamp') + nonce = req.args.get('nonce') + try: + wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) + except Exception as e: + raise Exception(f'初始化失败,错误码: {e}') + + if req.method == 'GET': + echostr = req.args.get('echostr') + ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + if ret != 0: + raise Exception(f'验证失败,错误码: {ret}') + return reply_echo_str + + elif req.method == 'POST': + encrypt_msg = await req.data + ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) + if ret != 0: + raise Exception(f'消息解密失败,错误码: {ret}') + + # 解析消息并处理 + message_data = await self.get_detailed_message_list(xml_msg) + if message_data is not None: + event = WecomCSEvent.from_payload(message_data) + if event: + await self._handle_message(event) + + return 'success' + except Exception as e: + if self.logger: + await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') + else: + traceback.print_exc() + return f'Error processing request: {str(e)}', 400 + + async def run_task(self, host: str, port: int, *args, **kwargs): + """ + 启动 Quart 应用。 + """ + await self.app.run_task(host=host, port=port, *args, **kwargs) + + def on_message(self, msg_type: str): + """ + 注册消息类型处理器。 + """ + + def decorator(func: Callable[[WecomCSEvent], None]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + + return decorator + + async def _handle_message(self, event: WecomCSEvent): + """ + 处理消息事件。 + """ + msg_type = event.type + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + + @staticmethod + async def get_image_type(image_bytes: bytes) -> str: + """ + 通过图片的magic numbers判断图片类型 + """ + magic_numbers = { + b'\xff\xd8\xff': 'jpg', + b'\x89\x50\x4e\x47': 'png', + b'\x47\x49\x46': 'gif', + b'\x42\x4d': 'bmp', + b'\x00\x00\x01\x00': 'ico', + } + + for magic, ext in magic_numbers.items(): + if image_bytes.startswith(magic): + return ext + return 'jpg' # 默认返回jpg + + async def upload_to_work(self, image: platform_message.Image): + """ + 获取 media_id + """ + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' + file_bytes = None + file_name = 'uploaded_file.txt' + + # 获取文件的二进制数据 + if image.path: + async with aiofiles.open(image.path, 'rb') as f: + file_bytes = await f.read() + file_name = image.path.split('/')[-1] + elif image.url: + file_bytes = await self.download_image_to_bytes(image.url) + file_name = image.url.split('/')[-1] + elif image.base64: + try: + base64_data = image.base64 + if ',' in base64_data: + base64_data = base64_data.split(',', 1)[1] + padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 + padded_base64 = base64_data + '=' * padding + file_bytes = base64.b64decode(padded_base64) + except binascii.Error as e: + raise ValueError(f'Invalid base64 string: {str(e)}') + else: + raise ValueError('image对象出错') + + # 设置 multipart/form-data 格式的文件 + boundary = '-------------------------acebdf13572468' + headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'} + body = ( + ( + f'--{boundary}\r\n' + f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n' + f'Content-Type: application/octet-stream\r\n\r\n' + ).encode('utf-8') + + file_bytes + + f'\r\n--{boundary}--\r\n'.encode('utf-8') + ) + + # 上传文件 + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, content=body) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + media_id = await self.upload_to_work(image) + if data.get('errcode', 0) != 0: + raise Exception('failed to upload file') + + media_id = data.get('media_id') + return media_id + + async def download_image_to_bytes(self, url: str) -> bytes: + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + return response.content + + # 进行media_id的获取 + async def get_media_id(self, image: platform_message.Image): + media_id = await self.upload_to_work(image=image) + return media_id + + async def get_customer_info(self, external_userid: str) -> dict | None: + """ + Get customer information by external_userid with caching. + + Uses a 1-minute cache to avoid repeated API calls for the same user. + + Args: + external_userid: The external user ID of the customer. + + Returns: + Customer info dict with 'nickname', 'avatar', etc., or None if not found. + """ + # Check cache first + current_time = time.time() + if external_userid in self._customer_cache: + cached_info, cached_time = self._customer_cache[external_userid] + if current_time - cached_time < self._cache_ttl: + return cached_info + + # Cache miss or expired, fetch from API + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}' + + payload = { + 'external_userid_list': [external_userid], + } + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload) + data = response.json() + + if data.get('errcode') in [40014, 42001]: + self.access_token = await self.get_access_token(self.secret) + return await self.get_customer_info(external_userid) + + if data.get('errcode', 0) != 0: + if self.logger: + await self.logger.warning(f'Failed to get customer info: {data}') + return None + + customer_list = data.get('customer_list', []) + if customer_list: + customer_info = customer_list[0] + # Store in cache + self._customer_cache[external_userid] = (customer_info, current_time) + return customer_info + return None diff --git a/src/langbot/libs/wecom_customer_service_api/wecomcsevent.py b/src/langbot/libs/wecom_customer_service_api/wecomcsevent.py new file mode 100644 index 0000000..ee830a7 --- /dev/null +++ b/src/langbot/libs/wecom_customer_service_api/wecomcsevent.py @@ -0,0 +1,132 @@ +from typing import Dict, Any, Optional + + +class WecomCSEvent(dict): + """ + 封装从企业微信收到的事件数据对象(字典),提供属性以获取其中的字段。 + + 除 `type` 和 `detail_type` 属性对于任何事件都有效外,其它属性是否存在(若不存在则返回 `None`)依事件类型不同而不同。 + """ + + @staticmethod + def from_payload(payload: Dict[str, Any]) -> Optional['WecomCSEvent']: + """ + 从企业微信(客服会话)事件数据构造 `WecomEvent` 对象。 + + Args: + payload (Dict[str, Any]): 解密后的企业微信事件数据。 + + Returns: + Optional[WecomEvent]: 如果事件数据合法,则返回 WecomEvent 对象;否则返回 None。 + """ + try: + event = WecomCSEvent(payload) + _ = (event.type,) + return event + except KeyError: + return None + + @property + def type(self) -> str: + """ + 事件类型,例如 "message"、"event"、"text" 等。 + + Returns: + str: 事件类型。 + """ + return self.get('msgtype', '') + + @property + def user_id(self) -> Optional[str]: + """ + 用户 ID,例如消息的发送者或事件的触发者。 + + Returns: + Optional[str]: 用户 ID。 + """ + return self.get('external_userid') + + @property + def receiver_id(self) -> Optional[str]: + """ + 接收者 ID,例如机器人自身的企业微信 ID。 + + Returns: + Optional[str]: 接收者 ID。 + """ + return self.get('open_kfid', '') + + @property + def picurl(self) -> Optional[str]: + """ + 图片 URL,仅在图片消息中存在。 + base64格式 + Returns: + Optional[str]: 图片 URL。 + """ + + return self.get('picurl', '') + + @property + def message_id(self) -> Optional[str]: + """ + 消息 ID,仅在消息类型事件中存在。 + + Returns: + Optional[str]: 消息 ID。 + """ + return self.get('msgid') + + @property + def message(self) -> Optional[str]: + """ + 消息内容,仅在消息类型事件中存在。 + + Returns: + Optional[str]: 消息内容。 + """ + if self.get('msgtype') == 'text': + return self.get('text').get('content', '') + else: + return None + + @property + def timestamp(self) -> Optional[int]: + """ + 事件发生的时间戳。 + + Returns: + Optional[int]: 时间戳。 + """ + return self.get('send_time') + + def __getattr__(self, key: str) -> Optional[Any]: + """ + 允许通过属性访问数据中的任意字段。 + + Args: + key (str): 字段名。 + + Returns: + Optional[Any]: 字段值。 + """ + return self.get(key) + + def __setattr__(self, key: str, value: Any) -> None: + """ + 允许通过属性设置数据中的任意字段。 + + Args: + key (str): 字段名。 + value (Any): 字段值。 + """ + self[key] = value + + def __repr__(self) -> str: + """ + 生成事件对象的字符串表示。 + + Returns: + str: 字符串表示。 + """ + return f'' diff --git a/src/langbot/libs/weknora_api/__init__.py b/src/langbot/libs/weknora_api/__init__.py new file mode 100644 index 0000000..2392610 --- /dev/null +++ b/src/langbot/libs/weknora_api/__init__.py @@ -0,0 +1,4 @@ +from .client import AsyncWeKnoraClient +from .errors import WeKnoraAPIError + +__all__ = ['AsyncWeKnoraClient', 'WeKnoraAPIError'] diff --git a/src/langbot/libs/weknora_api/client.py b/src/langbot/libs/weknora_api/client.py new file mode 100644 index 0000000..f753136 --- /dev/null +++ b/src/langbot/libs/weknora_api/client.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import httpx +import typing +import json + +from .errors import WeKnoraAPIError + + +class AsyncWeKnoraClient: + """WeKnora API 客户端""" + + api_key: str + base_url: str + + def __init__( + self, + api_key: str, + base_url: str = 'http://localhost:80/api/v1', + ) -> None: + self.api_key = api_key + self.base_url = base_url + + async def create_session( + self, + title: str = '', + description: str = '', + timeout: float = 30.0, + ) -> str: + """创建会话,返回 session_id""" + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + payload: dict[str, typing.Any] = {} + if title: + payload['title'] = title + if description: + payload['description'] = description + + response = await client.post( + '/sessions', + headers={ + 'X-API-Key': self.api_key, + 'Content-Type': 'application/json', + }, + json=payload, + ) + + if response.status_code not in (200, 201): + raise WeKnoraAPIError(f'{response.status_code} {response.text}') + + data = response.json() + return data['data']['id'] + + async def agent_chat( + self, + session_id: str, + query: str, + user: str, + agent_id: str = '', + knowledge_base_ids: list[str] | None = None, + web_search_enabled: bool = False, + timeout: float = 120.0, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """ + Agent 智能对话(SSE 流式) + + 响应事件类型: + - agent_query: Agent 开始处理 + - thinking: 思考过程 + - tool_call: 工具调用 + - tool_result: 工具结果 + - references: 知识库引用 + - answer: 回答内容 + - reflection: 反思 + - session_title: 会话标题 + - error: 错误 + """ + if knowledge_base_ids is None: + knowledge_base_ids = [] + + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + payload: dict[str, typing.Any] = { + 'query': query, + 'agent_enabled': True, + 'channel': 'im', + } + if agent_id: + payload['agent_id'] = agent_id + if knowledge_base_ids: + payload['knowledge_base_ids'] = knowledge_base_ids + if web_search_enabled: + payload['web_search_enabled'] = True + + async with client.stream( + 'POST', + f'/agent-chat/{session_id}', + headers={ + 'X-API-Key': self.api_key, + 'Content-Type': 'application/json', + }, + json=payload, + ) as r: + async for chunk in r.aiter_lines(): + if r.status_code != 200: + raise WeKnoraAPIError(f'{r.status_code} {chunk}') + if chunk.strip() == '': + continue + if chunk.startswith('data:'): + try: + data = json.loads(chunk[5:].strip()) + except json.JSONDecodeError: + continue + yield data + # 收到 error 事件后主动结束流,避免上层未 raise 时持续等待 + if data.get('response_type') == 'error': + return + + async def knowledge_chat( + self, + session_id: str, + query: str, + user: str, + agent_id: str = 'builtin-quick-answer', + knowledge_base_ids: list[str] | None = None, + timeout: float = 120.0, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """ + 知识库 RAG 问答(SSE 流式) + + 响应事件类型: + - references: 知识库引用 + - answer: 回答内容 + """ + if knowledge_base_ids is None: + knowledge_base_ids = [] + + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + payload: dict[str, typing.Any] = { + 'query': query, + 'channel': 'im', + } + if agent_id: + payload['agent_id'] = agent_id + if knowledge_base_ids: + payload['knowledge_base_ids'] = knowledge_base_ids + + async with client.stream( + 'POST', + f'/knowledge-chat/{session_id}', + headers={ + 'X-API-Key': self.api_key, + 'Content-Type': 'application/json', + }, + json=payload, + ) as r: + async for chunk in r.aiter_lines(): + if r.status_code != 200: + raise WeKnoraAPIError(f'{r.status_code} {chunk}') + if chunk.strip() == '': + continue + if chunk.startswith('data:'): + try: + data = json.loads(chunk[5:].strip()) + except json.JSONDecodeError: + continue + yield data + # 收到 error 事件后主动结束流,避免上层未 raise 时持续等待 + if data.get('response_type') == 'error': + return diff --git a/src/langbot/libs/weknora_api/errors.py b/src/langbot/libs/weknora_api/errors.py new file mode 100644 index 0000000..c43b724 --- /dev/null +++ b/src/langbot/libs/weknora_api/errors.py @@ -0,0 +1,6 @@ +class WeKnoraAPIError(Exception): + """WeKnora API 请求失败""" + + def __init__(self, message: str = ''): + self.message = message + super().__init__(self.message) diff --git a/src/langbot/pkg/__init__.py b/src/langbot/pkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/__init__.py b/src/langbot/pkg/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/http/__init__.py b/src/langbot/pkg/api/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/http/controller/__init__.py b/src/langbot/pkg/api/http/controller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py new file mode 100644 index 0000000..2ed5518 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/group.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import abc +import typing +import enum +import quart +import traceback +from quart.typing import RouteCallable + +from ....core import app + +# Maximum file upload size limit (10MB) +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + + +preregistered_groups: list[type[RouterGroup]] = [] +"""Pre-registered list of RouterGroup""" + + +def group_class(name: str, path: str) -> typing.Callable[[typing.Type[RouterGroup]], typing.Type[RouterGroup]]: + """注册一个 RouterGroup""" + + def decorator(cls: typing.Type[RouterGroup]) -> typing.Type[RouterGroup]: + cls.name = name + cls.path = path + preregistered_groups.append(cls) + return cls + + return decorator + + +class AuthType(enum.Enum): + """Authentication type""" + + NONE = 'none' + USER_TOKEN = 'user-token' + API_KEY = 'api-key' + USER_TOKEN_OR_API_KEY = 'user-token-or-api-key' + + +class RouterGroup(abc.ABC): + name: str + + path: str + + ap: app.Application + + quart_app: quart.Quart + + def __init__(self, ap: app.Application, quart_app: quart.Quart) -> None: + self.ap = ap + self.quart_app = quart_app + + @abc.abstractmethod + async def initialize(self) -> None: + pass + + def route( + self, + rule: str, + auth_type: AuthType = AuthType.USER_TOKEN, + **options: typing.Any, + ) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator + """Register a route""" + + def decorator(f: RouteCallable) -> RouteCallable: + nonlocal rule + rule = self.path + rule + + async def handler_error(*args, **kwargs): + if auth_type == AuthType.USER_TOKEN: + # get token from Authorization header + token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') + + if not token: + return self.http_status(401, -1, 'No valid user token provided') + + try: + user_email = await self.ap.user_service.verify_jwt_token(token) + + # check if this account exists + user = await self.ap.user_service.get_user_by_email(user_email) + if not user: + return self.http_status(401, -1, 'User not found') + + # check if f accepts user_email parameter + if 'user_email' in f.__code__.co_varnames: + kwargs['user_email'] = user_email + except Exception as e: + return self.http_status(401, -1, str(e)) + + elif auth_type == AuthType.API_KEY: + # get API key from Authorization header or X-API-Key header + api_key = quart.request.headers.get('X-API-Key', '') + if not api_key: + auth_header = quart.request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + api_key = auth_header.replace('Bearer ', '') + + if not api_key: + return self.http_status(401, -1, 'No valid API key provided') + + try: + is_valid = await self.ap.apikey_service.verify_api_key(api_key) + if not is_valid: + return self.http_status(401, -1, 'Invalid API key') + except Exception as e: + return self.http_status(401, -1, str(e)) + + elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: + # Try API key first (check X-API-Key header) + api_key = quart.request.headers.get('X-API-Key', '') + + if api_key: + # API key authentication + try: + is_valid = await self.ap.apikey_service.verify_api_key(api_key) + if not is_valid: + return self.http_status(401, -1, 'Invalid API key') + except Exception as e: + return self.http_status(401, -1, str(e)) + else: + # Try user token authentication (Authorization header) + token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') + + if not token: + return self.http_status( + 401, -1, 'No valid authentication provided (user token or API key required)' + ) + + try: + user_email = await self.ap.user_service.verify_jwt_token(token) + + # check if this account exists + user = await self.ap.user_service.get_user_by_email(user_email) + if not user: + return self.http_status(401, -1, 'User not found') + + # check if f accepts user_email parameter + if 'user_email' in f.__code__.co_varnames: + kwargs['user_email'] = user_email + except Exception: + # If user token fails, maybe it's an API key in Authorization header + try: + is_valid = await self.ap.apikey_service.verify_api_key(token) + if not is_valid: + return self.http_status(401, -1, 'Invalid authentication credentials') + except Exception as e: + return self.http_status(401, -1, str(e)) + + try: + return await f(*args, **kwargs) + + except Exception as e: # 自动 500 + traceback.print_exc() + # return self.http_status(500, -2, str(e)) + return self.http_status(500, -2, str(e)) + + new_f = handler_error + new_f.__name__ = (self.name + rule).replace('/', '__') + new_f.__doc__ = f.__doc__ + + self.quart_app.route(rule, **options)(new_f) + return f + + return decorator + + def success(self, data: typing.Any = None) -> quart.Response: + """Return a 200 response""" + return quart.jsonify( + { + 'code': 0, + 'msg': 'ok', + 'data': data, + } + ) + + def fail(self, code: int, msg: str) -> quart.Response: + """Return an error response""" + + return quart.jsonify( + { + 'code': code, + 'msg': msg, + } + ) + + def http_status(self, status: int, code: int, msg: str) -> typing.Tuple[quart.Response, int]: + """返回一个指定状态码的响应""" + return (self.fail(code, msg), status) diff --git a/src/langbot/pkg/api/http/controller/groups/__init__.py b/src/langbot/pkg/api/http/controller/groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/http/controller/groups/apikeys.py b/src/langbot/pkg/api/http/controller/groups/apikeys.py new file mode 100644 index 0000000..f53728b --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/apikeys.py @@ -0,0 +1,43 @@ +import quart + +from .. import group + + +@group.group_class('apikeys', '/api/v1/apikeys') +class ApiKeysRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST']) + async def _() -> str: + if quart.request.method == 'GET': + keys = await self.ap.apikey_service.get_api_keys() + return self.success(data={'keys': keys}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + name = json_data.get('name', '') + description = json_data.get('description', '') + + if not name: + return self.http_status(400, -1, 'Name is required') + + key = await self.ap.apikey_service.create_api_key(name, description) + return self.success(data={'key': key}) + + @self.route('/', methods=['GET', 'PUT', 'DELETE']) + async def _(key_id: int) -> str: + if quart.request.method == 'GET': + key = await self.ap.apikey_service.get_api_key(key_id) + if key is None: + return self.http_status(404, -1, 'API key not found') + return self.success(data={'key': key}) + + elif quart.request.method == 'PUT': + json_data = await quart.request.json + name = json_data.get('name') + description = json_data.get('description') + + await self.ap.apikey_service.update_api_key(key_id, name, description) + return self.success() + + elif quart.request.method == 'DELETE': + await self.ap.apikey_service.delete_api_key(key_id) + return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/box.py b/src/langbot/pkg/api/http/controller/groups/box.py new file mode 100644 index 0000000..d8c961e --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/box.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from langbot.pkg.utils import constants + +from .. import group +from .box_visibility import should_hide_box_runtime_status + + +@group.group_class('box', '/api/v1/box') +class BoxRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + status = await self.ap.box_service.get_status() + status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled')) + return self.success(data=status) + + @self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + sessions = await self.ap.box_service.get_sessions() + return self.success(data=sessions) + + @self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + errors = self.ap.box_service.get_recent_errors() + return self.success(data=errors) diff --git a/src/langbot/pkg/api/http/controller/groups/box_visibility.py b/src/langbot/pkg/api/http/controller/groups/box_visibility.py new file mode 100644 index 0000000..6672030 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/box_visibility.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +def should_hide_box_runtime_status(edition: str, box_enabled: bool | None) -> bool: + return edition == 'cloud' and box_enabled is False diff --git a/src/langbot/pkg/api/http/controller/groups/extensions.py b/src/langbot/pkg/api/http/controller/groups/extensions.py new file mode 100644 index 0000000..ac8463c --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/extensions.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import asyncio +import quart + +from .. import group + + +@group.group_class('extensions', '/api/v1/extensions') +class ExtensionsRouterGroup(group.RouterGroup): + """Unified API for installed extensions (plugins, MCP servers, skills).""" + + async def initialize(self) -> None: + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> quart.Response: + plugins, mcp_servers, skills = await asyncio.gather( + self.ap.plugin_connector.list_plugins(), + self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True), + self.ap.skill_service.list_skills(), + return_exceptions=True, + ) + + def _sort_key(item: dict) -> str: + if item['type'] == 'plugin': + return ( + item['plugin'] + .get('manifest', {}) + .get('manifest', {}) + .get('metadata', {}) + .get('name', '') + .lower() + ) + if item['type'] == 'mcp': + return (item['server'].get('name') or '').lower() + if item['type'] == 'skill': + return (item['skill'].get('display_name') or item['skill'].get('name') or '').lower() + return '' + + extensions: list[dict] = [] + if isinstance(plugins, list): + for plugin in plugins: + extensions.append({'type': 'plugin', 'plugin': plugin}) + if isinstance(mcp_servers, list): + for server in mcp_servers: + extensions.append({'type': 'mcp', 'server': server}) + if isinstance(skills, list): + for skill in skills: + extensions.append({'type': 'skill', 'skill': skill}) + + extensions.sort(key=_sort_key) + + return self.success(data={'extensions': extensions}) diff --git a/src/langbot/pkg/api/http/controller/groups/files.py b/src/langbot/pkg/api/http/controller/groups/files.py new file mode 100644 index 0000000..439cc57 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/files.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import quart +import mimetypes +import uuid +import asyncio + +import quart.datastructures + +from .. import group + + +@group.group_class('files', '/api/v1/files') +class FilesRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/image/', methods=['GET'], auth_type=group.AuthType.NONE) + async def _(image_key: str) -> quart.Response: + if '..' in image_key or '\\' in image_key: + return quart.Response(status=404) + + if not await self.ap.storage_mgr.storage_provider.exists(image_key): + return quart.Response(status=404) + + image_bytes = await self.ap.storage_mgr.storage_provider.load(image_key) + mime_type = mimetypes.guess_type(image_key)[0] + if mime_type is None: + mime_type = 'image/jpeg' + + return quart.Response(image_bytes, mimetype=mime_type) + + @self.route('/images', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def upload_image() -> quart.Response: + request = quart.request + + # Check file size limit before reading the file + content_length = request.content_length + if content_length and content_length > group.MAX_FILE_SIZE: + return self.fail(400, 'Image size exceeds 10MB limit.') + + # get file bytes from 'file' + files = await request.files + if 'file' not in files: + return self.fail(400, 'No image file provided') + + file = files['file'] + assert isinstance(file, quart.datastructures.FileStorage) + + file_bytes = await asyncio.to_thread(file.stream.read) + + # Double-check actual file size after reading + if len(file_bytes) > group.MAX_FILE_SIZE: + return self.fail(400, 'Image size exceeds 10MB limit.') + + # Validate image file extension + allowed_extensions = {'jpg', 'jpeg', 'png', 'gif', 'webp'} + if '.' in file.filename: + file_name, extension = file.filename.rsplit('.', 1) + extension = extension.lower() + else: + return self.fail(400, 'Invalid image file: no file extension') + + if extension not in allowed_extensions: + return self.fail(400, f'Invalid image format. Allowed formats: {", ".join(allowed_extensions)}') + + # check if file name contains '/' or '\' + if '/' in file_name or '\\' in file_name: + return self.fail(400, 'File name contains invalid characters') + + file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension + + # save file to storage + await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) + return self.success( + data={ + 'file_key': file_key, + } + ) + + @self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def upload_document() -> quart.Response: + request = quart.request + + # Check file size limit before reading the file + content_length = request.content_length + if content_length and content_length > group.MAX_FILE_SIZE: + return self.fail(400, 'File size exceeds 10MB limit. Please split large files into smaller parts.') + + # get file bytes from 'file' + files = await request.files + if 'file' not in files: + return self.fail(400, 'No file provided in request') + + file = files['file'] + assert isinstance(file, quart.datastructures.FileStorage) + + file_bytes = await asyncio.to_thread(file.stream.read) + + # Double-check actual file size after reading + if len(file_bytes) > group.MAX_FILE_SIZE: + return self.fail(400, 'File size exceeds 10MB limit. Please split large files into smaller parts.') + + # Split filename and extension properly + if '.' in file.filename: + file_name, extension = file.filename.rsplit('.', 1) + else: + file_name = file.filename + extension = '' + + # check if file name contains '/' or '\' + if '/' in file_name or '\\' in file_name: + return self.fail(400, 'File name contains invalid characters') + + file_key = file_name + '_' + str(uuid.uuid4())[:8] + if extension: + file_key += '.' + extension + + # save file to storage + await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) + return self.success( + data={ + 'file_id': file_key, + } + ) diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/__init__.py b/src/langbot/pkg/api/http/controller/groups/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py new file mode 100644 index 0000000..4f9bb5b --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py @@ -0,0 +1,109 @@ +import quart +from ... import group + + +@group.group_class('knowledge_base', '/api/v1/knowledge/bases') +class KnowledgeBaseRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['POST', 'GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def handle_knowledge_bases() -> quart.Response: + if quart.request.method == 'GET': + knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases() + return self.success(data={'bases': knowledge_bases}) + + elif quart.request.method == 'POST': + json_data = await quart.request.json + try: + knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(json_data) + except ValueError as e: + return self.http_status(400, -1, str(e)) + return self.success(data={'uuid': knowledge_base_uuid}) + + return self.http_status(405, -1, 'Method not allowed') + + @self.route( + '/', + methods=['GET', 'DELETE', 'PUT'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def handle_specific_knowledge_base(knowledge_base_uuid: str) -> quart.Response: + if quart.request.method == 'GET': + knowledge_base = await self.ap.knowledge_service.get_knowledge_base(knowledge_base_uuid) + + if knowledge_base is None: + return self.http_status(404, -1, 'knowledge base not found') + + return self.success( + data={ + 'base': knowledge_base, + } + ) + + elif quart.request.method == 'PUT': + json_data = await quart.request.json + await self.ap.knowledge_service.update_knowledge_base(knowledge_base_uuid, json_data) + return self.success(data={'uuid': knowledge_base_uuid}) + + elif quart.request.method == 'DELETE': + await self.ap.knowledge_service.delete_knowledge_base(knowledge_base_uuid) + return self.success({}) + + @self.route( + '//files', + methods=['GET', 'POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def get_knowledge_base_files(knowledge_base_uuid: str) -> str: + if quart.request.method == 'GET': + files = await self.ap.knowledge_service.get_files_by_knowledge_base(knowledge_base_uuid) + return self.success( + data={ + 'files': files, + } + ) + + elif quart.request.method == 'POST': + json_data = await quart.request.json + file_id = json_data.get('file_id') + if not file_id: + return self.http_status(400, -1, 'File ID is required') + + parser_plugin_id = json_data.get('parser_plugin_id') + + # 调用服务层方法将文件与知识库关联 + task_id = await self.ap.knowledge_service.store_file( + knowledge_base_uuid, file_id, parser_plugin_id=parser_plugin_id + ) + return self.success( + { + 'task_id': task_id, + } + ) + + @self.route( + '//files/', + methods=['DELETE'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def delete_specific_file_in_kb(file_id: str, knowledge_base_uuid: str) -> str: + await self.ap.knowledge_service.delete_file(knowledge_base_uuid, file_id) + return self.success({}) + + @self.route( + '//retrieve', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def retrieve_knowledge_base(knowledge_base_uuid: str) -> str: + json_data = await quart.request.json + query = json_data.get('query') + + if not query or not query.strip(): + return self.http_status(400, -1, 'Query is required and cannot be empty') + + # Extract retrieval_settings to allow dynamic control over Knowledge Engine behavior (e.g. top_k, filters) + retrieval_settings = json_data.get('retrieval_settings', {}) + results = await self.ap.knowledge_service.retrieve_knowledge_base( + knowledge_base_uuid, query, retrieval_settings + ) + return self.success(data={'results': results}) diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py b/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py new file mode 100644 index 0000000..28f0710 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py @@ -0,0 +1,45 @@ +import quart +from urllib.parse import unquote +from ... import group + + +@group.group_class('knowledge_engines', '/api/v1/knowledge/engines') +class KnowledgeEnginesRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def list_knowledge_engines() -> quart.Response: + """List all available Knowledge Engines from plugins. + + Returns a list of Knowledge Engines with their capabilities and configuration schemas. + This is used by the frontend to render the knowledge base creation wizard. + """ + engines = await self.ap.knowledge_service.list_knowledge_engines() + return self.success(data={'engines': engines}) + + @self.route( + '//creation-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def get_engine_creation_schema(plugin_id: str) -> quart.Response: + """Get creation settings schema for a specific Knowledge Engine. + + plugin_id is in 'author/name' format, captured via converter. + """ + plugin_id = unquote(plugin_id) + if '/' not in plugin_id: + return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.') + schema = await self.ap.knowledge_service.get_engine_creation_schema(plugin_id) + return self.success(data={'schema': schema}) + + @self.route( + '//retrieval-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def get_engine_retrieval_schema(plugin_id: str) -> quart.Response: + """Get retrieval settings schema for a specific Knowledge Engine. + + plugin_id is in 'author/name' format, captured via converter. + """ + plugin_id = unquote(plugin_id) + if '/' not in plugin_id: + return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.') + schema = await self.ap.knowledge_service.get_engine_retrieval_schema(plugin_id) + return self.success(data={'schema': schema}) diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/migration.py b/src/langbot/pkg/api/http/controller/groups/knowledge/migration.py new file mode 100644 index 0000000..2db835d --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/migration.py @@ -0,0 +1,372 @@ +import asyncio +import json + +import httpx +import quart +import sqlalchemy + +from ... import group +from ......core import taskmgr +from ......entity.persistence import metadata as persistence_metadata +from langbot_plugin.runtime.plugin.mgr import PluginInstallSource + +LANGRAG_PLUGIN_AUTHOR = 'langbot-team' +LANGRAG_PLUGIN_NAME = 'LangRAG' +LANGRAG_PLUGIN_ID = f'{LANGRAG_PLUGIN_AUTHOR}/{LANGRAG_PLUGIN_NAME}' +DEFAULT_SPACE_URL = 'https://space.langbot.app' + +# Old Retriever plugin_name -> New Connector plugin_name +EXTERNAL_PLUGIN_NAME_MAPPING = { + 'DifyDatasetsRetriever': 'DifyDatasetsConnector', + 'RAGFlowRetriever': 'RAGFlowConnector', + 'FastGPTRetriever': 'FastGPTConnector', +} + +# Per-plugin: which old retriever_config fields belong to creation_settings. +# Remaining fields go to retrieval_settings. +# None means ALL fields go to creation_settings (no retrieval_schema). +EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = { + 'langbot-team/DifyDatasetsConnector': {'api_base_url', 'dify_apikey', 'dataset_id'}, + 'langbot-team/RAGFlowConnector': {'api_base_url', 'api_key', 'dataset_ids'}, + 'langbot-team/FastGPTConnector': None, # all fields -> creation_settings +} + + +@group.group_class('knowledge/migration', '/api/v1/knowledge/migration') +class KnowledgeMigrationRouterGroup(group.RouterGroup): + async def _get_migration_flag(self) -> bool: + """Check if rag_plugin_migration_needed flag is set.""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_metadata.Metadata).where( + persistence_metadata.Metadata.key == 'rag_plugin_migration_needed' + ) + ) + row = result.first() + return row is not None and row.value == 'true' + + async def _set_migration_flag(self, value: str): + """Set rag_plugin_migration_needed flag.""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_metadata.Metadata) + .where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed') + .values(value=value) + ) + + async def _table_exists(self, table_name: str) -> bool: + """Check if a table exists.""" + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' + ).bindparams(table_name=table_name) + ) + return result.scalar() + else: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( + table_name=table_name + ) + ) + return result.first() is not None + + async def _install_plugin_from_marketplace( + self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str + ) -> None: + """Install a single plugin from the marketplace.""" + p_author, p_name = plugin_id.split('/', 1) + self.ap.logger.info(f'RAG migration: installing plugin {plugin_id} from marketplace...') + task_context.trace(f'Installing plugin {plugin_id} from marketplace...') + + async with httpx.AsyncClient(trust_env=True, timeout=15) as client: + resp = await client.get(f'{space_url}/api/v1/marketplace/plugins/{p_author}/{p_name}') + resp.raise_for_status() + p_data = resp.json().get('data', {}).get('plugin', {}) + p_version = p_data.get('latest_version') + if not p_version: + raise Exception(f'Could not determine latest version for {plugin_id}') + + await self.ap.plugin_connector.install_plugin( + PluginInstallSource.MARKETPLACE, + { + 'plugin_author': p_author, + 'plugin_name': p_name, + 'plugin_version': p_version, + }, + task_context=task_context, + ) + self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.') + + async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True): + """Execute RAG migration: install required plugins and restore backup data.""" + warnings = [] + + # Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs) + needed_plugins: dict[str, str] = { + LANGRAG_PLUGIN_ID: LANGRAG_PLUGIN_NAME, + } + + has_external = await self._table_exists('external_knowledge_bases') + if has_external: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT DISTINCT plugin_author, plugin_name FROM external_knowledge_bases;') + ) + for row in result.fetchall(): + plugin_author = row[0] or '' + plugin_name = row[1] or '' + mapped_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name) + plugin_id = f'{plugin_author}/{mapped_name}' + if plugin_id not in needed_plugins: + needed_plugins[plugin_id] = mapped_name + + self.ap.logger.info(f'RAG migration: plugins needed: {list(needed_plugins.keys())}') + + if install_plugin: + # Step 1: Install all required plugins from marketplace + task_context.trace('Installing required plugins...', action='install-plugin') + space_url = self.ap.instance_config.data.get('space', {}).get('url', DEFAULT_SPACE_URL).rstrip('/') + + for plugin_id in needed_plugins: + try: + await self._install_plugin_from_marketplace(plugin_id, task_context, space_url) + except Exception as e: + self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}') + task_context.trace(f'Plugin install note ({plugin_id}): {e}') + + # Step 2: Wait for all plugins to become available as knowledge engines + task_context.trace( + f'Waiting for plugins to become available: {list(needed_plugins.keys())}...', + action='wait-plugin', + ) + max_retries = 30 + engine_id_set: set[str] = set() + for i in range(max_retries): + try: + engines = await self.ap.plugin_connector.list_knowledge_engines() + engine_id_set = {e.get('plugin_id') for e in engines} + except Exception: + pass + if all(pid in engine_id_set for pid in needed_plugins): + self.ap.logger.info(f'RAG migration: all plugins ready: {engine_id_set}') + task_context.trace('All required plugins are ready.') + break + if i == max_retries - 1: + still_missing = [pid for pid in needed_plugins if pid not in engine_id_set] + warning = f'Plugin(s) {still_missing} did not become available after {max_retries} retries' + self.ap.logger.warning(f'RAG migration: {warning}') + warnings.append(warning) + task_context.trace(warning) + await asyncio.sleep(2) + else: + try: + engines = await self.ap.plugin_connector.list_knowledge_engines() + engine_id_set = {e.get('plugin_id') for e in engines} + except Exception: + engine_id_set = set() + + # Step 3: Restore internal knowledge bases from backup + task_context.trace('Restoring internal knowledge bases...', action='restore-internal') + if await self._table_exists('knowledge_bases_backup'): + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT * FROM knowledge_bases_backup;') + ) + rows = result.fetchall() + columns = result.keys() + + for row in rows: + row_dict = dict(zip(columns, row)) + kb_uuid = row_dict.get('uuid') + name = row_dict.get('name', 'Untitled') + description = row_dict.get('description', '') + emoji = row_dict.get('emoji', '\U0001f4da') + embedding_model_uuid = row_dict.get('embedding_model_uuid', '') + top_k = row_dict.get('top_k', 5) + created_at = row_dict.get('created_at') + updated_at = row_dict.get('updated_at') + + creation_settings = json.dumps({'embedding_model_uuid': embedding_model_uuid}) + retrieval_settings = json.dumps({'top_k': top_k}) + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'INSERT INTO knowledge_bases ' + '(uuid, name, description, emoji, created_at, updated_at, ' + 'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) ' + 'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, ' + ':plugin_id, :collection_id, :creation_settings, :retrieval_settings);' + ).bindparams( + uuid=kb_uuid, + name=name, + description=description, + emoji=emoji, + created_at=created_at, + updated_at=updated_at, + plugin_id=LANGRAG_PLUGIN_ID, + collection_id=kb_uuid, + creation_settings=creation_settings, + retrieval_settings=retrieval_settings, + ) + ) + + try: + config = {'embedding_model_uuid': embedding_model_uuid} + await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config) + task_context.trace(f'Restored internal KB: {name} ({kb_uuid})') + except Exception as e: + warning = f'Failed to notify plugin for KB {name} ({kb_uuid}): {e}' + warnings.append(warning) + task_context.trace(warning) + + await self.ap.rag_mgr.load_knowledge_bases_from_db() + + # Step 4: Restore external knowledge bases + task_context.trace('Restoring external knowledge bases...', action='restore-external') + if has_external: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT * FROM external_knowledge_bases;') + ) + rows = result.fetchall() + columns = result.keys() + + self.ap.logger.info( + f'RAG migration: {len(rows)} external KB(s) to restore. Available engines: {engine_id_set}' + ) + task_context.trace(f'Found {len(rows)} external KB(s). Available engines: {engine_id_set}') + + for row in rows: + row_dict = dict(zip(columns, row)) + kb_uuid = row_dict.get('uuid') + name = row_dict.get('name', 'Untitled') + description = row_dict.get('description', '') + emoji = row_dict.get('emoji', '\U0001f517') + plugin_author = row_dict.get('plugin_author', '') + plugin_name = row_dict.get('plugin_name', '') + retriever_config = row_dict.get('retriever_config', {}) + created_at = row_dict.get('created_at') + + mapped_plugin_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name) + external_plugin_id = f'{plugin_author}/{mapped_plugin_name}' + + self.ap.logger.info( + f'RAG migration: processing external KB "{name}" ({kb_uuid}), ' + f'plugin: {plugin_author}/{plugin_name} -> {external_plugin_id}' + ) + + if isinstance(retriever_config, str): + try: + retriever_config = json.loads(retriever_config) + except (json.JSONDecodeError, TypeError): + retriever_config = {} + + creation_fields = EXTERNAL_PLUGIN_CREATION_FIELDS.get(external_plugin_id) + if creation_fields is None: + creation_settings_dict = retriever_config + retrieval_settings_dict = {} + else: + creation_settings_dict = {k: v for k, v in retriever_config.items() if k in creation_fields} + retrieval_settings_dict = {k: v for k, v in retriever_config.items() if k not in creation_fields} + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'INSERT INTO knowledge_bases ' + '(uuid, name, description, emoji, created_at, updated_at, ' + 'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) ' + 'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, ' + ':plugin_id, :collection_id, :creation_settings, :retrieval_settings);' + ).bindparams( + uuid=kb_uuid, + name=name, + description=description, + emoji=emoji, + created_at=created_at, + updated_at=created_at, + plugin_id=external_plugin_id, + collection_id=kb_uuid, + creation_settings=json.dumps(creation_settings_dict), + retrieval_settings=json.dumps(retrieval_settings_dict), + ) + ) + + if external_plugin_id not in engine_id_set: + warning = ( + f'External KB "{name}" ({kb_uuid}) record saved, but plugin {external_plugin_id} ' + f'is not installed yet. Install the connector plugin to use it.' + ) + warnings.append(warning) + task_context.trace(warning) + else: + try: + await self.ap.plugin_connector.rag_on_kb_create( + external_plugin_id, kb_uuid, creation_settings_dict + ) + task_context.trace(f'Restored external KB: {name} ({kb_uuid})') + except Exception as e: + warning = f'Failed to notify plugin for external KB {name} ({kb_uuid}): {e}' + warnings.append(warning) + task_context.trace(warning) + + await self.ap.rag_mgr.load_knowledge_bases_from_db() + + # Step 5: Clear migration flag + await self._set_migration_flag('false') + task_context.trace('RAG migration completed.', action='done') + + if warnings: + task_context.trace(f'Completed with {len(warnings)} warning(s).') + + async def initialize(self) -> None: + @self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + needed = await self._get_migration_flag() + + internal_kb_count = 0 + external_kb_count = 0 + + if needed: + if await self._table_exists('knowledge_bases_backup'): + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases_backup;') + ) + internal_kb_count = result.scalar() or 0 + + if await self._table_exists('external_knowledge_bases'): + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;') + ) + external_kb_count = result.scalar() or 0 + + return self.success( + data={ + 'needed': needed, + 'internal_kb_count': internal_kb_count, + 'external_kb_count': external_kb_count, + } + ) + + @self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + needed = await self._get_migration_flag() + if not needed: + return self.http_status(400, -1, 'RAG migration is not needed') + + data = await quart.request.get_json(silent=True) or {} + install_plugin = data.get('install_plugin', True) + + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin), + kind='rag-migration', + name='rag-migration-execute', + label='Migrating knowledge bases to plugin architecture', + context=ctx, + ) + + return self.success(data={'task_id': wrapper.id}) + + @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + needed = await self._get_migration_flag() + if not needed: + return self.http_status(400, -1, 'RAG migration is not needed') + + await self._set_migration_flag('false') + return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py b/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py new file mode 100644 index 0000000..a5e853c --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py @@ -0,0 +1,16 @@ +import quart +from ... import group + + +@group.group_class('parsers', '/api/v1/knowledge/parsers') +class ParsersRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def list_parsers() -> quart.Response: + """List all available parsers from plugins. + + Optional query parameter `mime_type` to filter parsers by supported MIME type. + """ + mime_type = quart.request.args.get('mime_type') + parsers = await self.ap.knowledge_service.list_parsers(mime_type) + return self.success(data={'parsers': parsers}) diff --git a/src/langbot/pkg/api/http/controller/groups/logs.py b/src/langbot/pkg/api/http/controller/groups/logs.py new file mode 100644 index 0000000..e3bff9d --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/logs.py @@ -0,0 +1,27 @@ +from __future__ import annotations + + +import quart + +from .. import group + + +@group.group_class('logs', '/api/v1/logs') +class LogsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + start_page_number = int(quart.request.args.get('start_page_number', 0)) + start_offset = int(quart.request.args.get('start_offset', 0)) + + logs_str, end_page_number, end_offset = self.ap.log_cache.get_log_by_pointer( + start_page_number=start_page_number, start_offset=start_offset + ) + + return self.success( + data={ + 'logs': logs_str, + 'end_page_number': end_page_number, + 'end_offset': end_offset, + } + ) diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py new file mode 100644 index 0000000..29dedca --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -0,0 +1,642 @@ +from __future__ import annotations + +import datetime +import quart + +from .. import group + + +def parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None: + """Parse ISO 8601 datetime string, handling 'Z' suffix for UTC timezone""" + if not datetime_str: + return None + # Replace 'Z' with '+00:00' for Python 3.10 compatibility + if datetime_str.endswith('Z'): + datetime_str = datetime_str[:-1] + '+00:00' + dt = datetime.datetime.fromisoformat(datetime_str) + # Convert to UTC and remove timezone info to match database storage (which stores UTC as naive datetime) + if dt.tzinfo is not None: + # Convert to UTC and remove timezone info + dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return dt + + +@group.group_class('monitoring', '/api/v1/monitoring') +class MonitoringRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/overview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_overview() -> str: + """Get overview metrics""" + # Parse query parameters + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + metrics = await self.ap.monitoring_service.get_overview_metrics( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + ) + + return self.success(data=metrics) + + @self.route('/token-statistics', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_token_statistics() -> str: + """Get detailed token usage statistics (summary, per-model, timeseries).""" + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + bucket = quart.request.args.get('bucket', 'hour') + if bucket not in ('hour', 'day'): + bucket = 'hour' + + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + stats = await self.ap.monitoring_service.get_token_statistics( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + bucket=bucket, + ) + + return self.success(data=stats) + + @self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_messages() -> str: + """Get message logs""" + # Parse query parameters + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + session_ids = quart.request.args.getlist('sessionId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + limit = int(quart.request.args.get('limit', 100)) + offset = int(quart.request.args.get('offset', 0)) + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + messages, total = await self.ap.monitoring_service.get_messages( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + session_ids=session_ids if session_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + + return self.success( + data={ + 'messages': messages, + 'total': total, + 'limit': limit, + 'offset': offset, + } + ) + + @self.route('/llm-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_llm_calls() -> str: + """Get LLM call records""" + # Parse query parameters + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + limit = int(quart.request.args.get('limit', 100)) + offset = int(quart.request.args.get('offset', 0)) + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + llm_calls, total = await self.ap.monitoring_service.get_llm_calls( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + + return self.success( + data={ + 'llm_calls': llm_calls, + 'total': total, + 'limit': limit, + 'offset': offset, + } + ) + + @self.route('/tool-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_tool_calls() -> str: + """Get tool call records""" + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + session_ids = quart.request.args.getlist('sessionId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + limit = int(quart.request.args.get('limit', 100)) + offset = int(quart.request.args.get('offset', 0)) + + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + tool_calls, total = await self.ap.monitoring_service.get_tool_calls( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + session_ids=session_ids if session_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + + return self.success( + data={ + 'tool_calls': tool_calls, + 'total': total, + 'limit': limit, + 'offset': offset, + } + ) + + @self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_embedding_calls() -> str: + """Get embedding call records""" + # Parse query parameters + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + knowledge_base_id = quart.request.args.get('knowledgeBaseId') + limit = int(quart.request.args.get('limit', 100)) + offset = int(quart.request.args.get('offset', 0)) + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls( + start_time=start_time, + end_time=end_time, + knowledge_base_id=knowledge_base_id if knowledge_base_id else None, + limit=limit, + offset=offset, + ) + + return self.success( + data={ + 'embedding_calls': embedding_calls, + 'total': total, + 'limit': limit, + 'offset': offset, + } + ) + + @self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_sessions() -> str: + """Get session information""" + # Parse query parameters + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + is_active_str = quart.request.args.get('isActive') + limit = int(quart.request.args.get('limit', 100)) + offset = int(quart.request.args.get('offset', 0)) + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + # Parse is_active + is_active = None + if is_active_str: + is_active = is_active_str.lower() == 'true' + + sessions, total = await self.ap.monitoring_service.get_sessions( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + is_active=is_active, + limit=limit, + offset=offset, + ) + + return self.success( + data={ + 'sessions': sessions, + 'total': total, + 'limit': limit, + 'offset': offset, + } + ) + + @self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_errors() -> str: + """Get error logs""" + # Parse query parameters + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + limit = int(quart.request.args.get('limit', 100)) + offset = int(quart.request.args.get('offset', 0)) + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + errors, total = await self.ap.monitoring_service.get_errors( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + + return self.success( + data={ + 'errors': errors, + 'total': total, + 'limit': limit, + 'offset': offset, + } + ) + + @self.route('/data', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_all_data() -> str: + """Get all monitoring data in a single request""" + # Parse query parameters + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + limit = int(quart.request.args.get('limit', 50)) + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + # Get overview metrics + overview = await self.ap.monitoring_service.get_overview_metrics( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + ) + + # Get messages + messages, messages_total = await self.ap.monitoring_service.get_messages( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=0, + ) + + # Get LLM calls + llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=0, + ) + + # Get tool calls + tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=0, + ) + + # Get sessions + sessions, sessions_total = await self.ap.monitoring_service.get_sessions( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + is_active=None, + limit=limit, + offset=0, + ) + + # Get errors + errors, errors_total = await self.ap.monitoring_service.get_errors( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=0, + ) + + # Get embedding calls + embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls( + start_time=start_time, + end_time=end_time, + limit=limit, + offset=0, + ) + + return self.success( + data={ + 'overview': overview, + 'messages': messages, + 'llmCalls': llm_calls, + 'toolCalls': tool_calls, + 'embeddingCalls': embedding_calls, + 'sessions': sessions, + 'errors': errors, + 'totalCount': { + 'messages': messages_total, + 'llmCalls': llm_calls_total, + 'toolCalls': tool_calls_total, + 'embeddingCalls': embedding_calls_total, + 'sessions': sessions_total, + 'errors': errors_total, + }, + } + ) + + @self.route('/sessions//analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_session_analysis(session_id: str) -> str: + """Get detailed analysis for a specific session""" + analysis = await self.ap.monitoring_service.get_session_analysis(session_id) + + # Always return success with the analysis data + # The frontend will handle the 'found: false' case + return self.success(data=analysis) + + @self.route('/messages//details', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_message_details(message_id: str) -> str: + """Get detailed information for a specific message""" + details = await self.ap.monitoring_service.get_message_details(message_id) + + if not details.get('found'): + return self.error(message=f'Message {message_id} not found', code=404) + + return self.success(data=details) + + @self.route('/export', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def export_data() -> tuple[str, int]: + """Export monitoring data as CSV""" + # Parse query parameters + export_type = quart.request.args.get('type', 'messages') + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + limit = int(quart.request.args.get('limit', 100000)) + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + # Get data based on export type + if export_type == 'messages': + data = await self.ap.monitoring_service.export_messages( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'id', + 'timestamp', + 'bot_id', + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'runner_name', + 'message_content', + 'message_text', + 'session_id', + 'status', + 'level', + 'platform', + 'user_id', + ] + elif export_type == 'llm-calls': + data = await self.ap.monitoring_service.export_llm_calls( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'id', + 'timestamp', + 'model_name', + 'input_tokens', + 'output_tokens', + 'total_tokens', + 'duration_ms', + 'cost', + 'status', + 'bot_id', + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'session_id', + 'message_id', + 'error_message', + ] + elif export_type == 'embedding-calls': + data = await self.ap.monitoring_service.export_embedding_calls( + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'id', + 'timestamp', + 'model_name', + 'prompt_tokens', + 'total_tokens', + 'duration_ms', + 'input_count', + 'status', + 'error_message', + 'knowledge_base_id', + 'query_text', + 'session_id', + 'message_id', + 'call_type', + ] + elif export_type == 'errors': + data = await self.ap.monitoring_service.export_errors( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'id', + 'timestamp', + 'error_type', + 'error_message', + 'bot_id', + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'session_id', + 'message_id', + 'stack_trace', + ] + elif export_type == 'sessions': + data = await self.ap.monitoring_service.export_sessions( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'session_id', + 'bot_id', + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'message_count', + 'start_time', + 'last_activity', + 'is_active', + 'platform', + 'user_id', + ] + elif export_type == 'feedback': + data = await self.ap.monitoring_service.export_feedback( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'id', + 'timestamp', + 'feedback_id', + 'feedback_type', + 'feedback_content', + 'inaccurate_reasons', + 'bot_id', + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'session_id', + 'message_id', + 'stream_id', + 'user_id', + 'platform', + ] + else: + return self.error(message=f'Invalid export type: {export_type}', code=400) + + # Generate CSV content with UTF-8 BOM for Excel compatibility + import io + + output = io.StringIO() + # Write UTF-8 BOM for Excel + output.write('\ufeff') + # Write header + output.write(','.join(headers) + '\n') + + # Escape and write each row + for row in data: + escaped_values = [] + for header in headers: + value = row.get(header, '') + escaped_values.append(self.ap.monitoring_service._escape_csv_field(value)) + output.write(','.join(escaped_values) + '\n') + + csv_content = output.getvalue() + + # Return as file download + response = await quart.make_response(csv_content) + response.headers['Content-Type'] = 'text/csv; charset=utf-8' + response.headers['Content-Disposition'] = ( + f'attachment; filename="monitoring-{export_type}-{int(datetime.datetime.now().timestamp())}.csv"' + ) + + return response, 200 + + @self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_feedback_stats() -> str: + """Get feedback statistics""" + # Parse query parameters + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + stats = await self.ap.monitoring_service.get_feedback_stats( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + ) + + return self.success(data=stats) + + @self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_feedback() -> str: + """Get feedback list""" + # Parse query parameters + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + feedback_type_str = quart.request.args.get('feedbackType') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + limit = int(quart.request.args.get('limit', 100)) + offset = int(quart.request.args.get('offset', 0)) + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + # Parse feedback type + feedback_type = int(feedback_type_str) if feedback_type_str else None + + feedback_list, total = await self.ap.monitoring_service.get_feedback_list( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + feedback_type=feedback_type, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + + return self.success( + data={ + 'feedback': feedback_list, + 'total': total, + 'limit': limit, + 'offset': offset, + } + ) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/__init__.py b/src/langbot/pkg/api/http/controller/groups/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py new file mode 100644 index 0000000..99c0094 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py @@ -0,0 +1,384 @@ +"""Embed widget routes - serve embeddable chat widget for external websites. + +All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that +internal pipeline identifiers are never exposed to end-users. Each handler +resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts +the bound pipeline_uuid for internal routing. +""" + +import asyncio +import datetime +import json +import logging +import uuid +import hmac +import hashlib +import time +import re +import httpx + +import quart + +from ... import group +from ......utils import paths +from ......platform.sources.websocket_manager import ws_connection_manager + +logger = logging.getLogger(__name__) + +# Cache the widget template content +_widget_template_cache: str | None = None +_logo_bytes_cache: bytes | None = None + + +def _is_valid_uuid(s: str) -> bool: + return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s)) + + +def _get_widget_template() -> str: + """Load and cache the widget JS template.""" + global _widget_template_cache + if _widget_template_cache is None: + template_path = paths.get_resource_path('templates/embed/widget.js') + with open(template_path, 'r', encoding='utf-8') as f: + _widget_template_cache = f.read() + return _widget_template_cache + + +def _get_logo_bytes() -> bytes: + """Load and cache the logo image.""" + global _logo_bytes_cache + if _logo_bytes_cache is None: + logo_path = paths.get_resource_path('templates/embed/logo.webp') + with open(logo_path, 'rb') as f: + _logo_bytes_cache = f.read() + return _logo_bytes_cache + + +@group.group_class('embed', '/api/v1/embed') +class EmbedRouterGroup(group.RouterGroup): + # -- helpers ------------------------------------------------------------- + + def _resolve_bot(self, bot_uuid: str): + """Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``. + + Returns ``(None, None)`` when the bot does not exist, is not a + ``web_page_bot``, is disabled, or has no pipeline bound. + """ + for bot in self.ap.platform_mgr.bots: + if ( + bot.bot_entity.uuid == bot_uuid + and bot.bot_entity.adapter == 'web_page_bot' + and bot.bot_entity.enable + and bot.bot_entity.use_pipeline_uuid + ): + return bot, bot.bot_entity.use_pipeline_uuid + return None, None + + def _get_bot_config(self, bot_uuid: str) -> dict: + for bot in self.ap.platform_mgr.bots: + if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot': + return bot.bot_entity.adapter_config + return {} + + async def _verify_session_token(self, request, bot_uuid: str) -> bool: + config = self._get_bot_config(bot_uuid) + secret = config.get('turnstile_secret_key', '') + if not secret: + return True + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return False + token = auth_header[7:] + try: + ts_str, mac = token.split('.', 1) + ts = float(ts_str) + if time.time() - ts > 86400: + return False + expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(mac, expected_mac) + except Exception: + return False + + # -- routes -------------------------------------------------------------- + + async def initialize(self) -> None: + @self.route('//turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE) + async def verify_turnstile(bot_uuid: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + try: + data = await quart.request.get_json() + token = data.get('token') + if not token: + return self.http_status(400, -1, 'Token is required') + + config = self._get_bot_config(bot_uuid) + secret = config.get('turnstile_secret_key', '') + if not secret: + ts = time.time() + return self.success(data={'token': f'{ts}.dummy'}) + + async with httpx.AsyncClient() as client: + resp = await client.post( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + data={'secret': secret, 'response': token}, + ) + result = resp.json() + + if not result.get('success'): + return self.http_status(403, -1, 'Turnstile verification failed') + + ts = time.time() + mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest() + session_token = f'{ts}.{mac}' + + return self.success(data={'token': session_token}) + + except Exception as e: + logger.error(f'Turnstile verify failed: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + @self.route('//widget.js', methods=['GET'], auth_type=group.AuthType.NONE) + async def serve_widget(bot_uuid: str) -> quart.Response: + """Serve the embed widget JavaScript with injected configuration.""" + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return quart.Response( + '// Bot not found or not available', status=404, content_type='application/javascript' + ) + try: + template = _get_widget_template() + except FileNotFoundError: + return quart.Response('// Widget template not found', status=404, content_type='application/javascript') + + base_url = quart.request.host_url.rstrip('/') + webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '') + if webhook_prefix: + base_url = webhook_prefix.rstrip('/') + + if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url): + base_url = quart.request.host_url.rstrip('/') + + config = self._get_bot_config(bot_uuid) + site_key = config.get('turnstile_site_key', '') + locale = config.get('language', 'en_US') or 'en_US' + bubble_icon = config.get('bubble_icon', 'logo') or 'logo' + widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key) + widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid) + widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url) + widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale) + widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon) + + response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8') + response.headers['Cache-Control'] = 'public, max-age=300' + return response + + @self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE) + async def serve_logo() -> quart.Response: + """Serve the LangBot logo for the embed widget.""" + try: + logo_data = _get_logo_bytes() + except FileNotFoundError: + return quart.Response('', status=404) + + response = quart.Response(logo_data, content_type='image/webp') + response.headers['Cache-Control'] = 'public, max-age=86400' + return response + + @self.route('//messages/', methods=['GET'], auth_type=group.AuthType.NONE) + async def get_embed_messages(bot_uuid: str, session_type: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + if not await self._verify_session_token(quart.request, bot_uuid): + return self.http_status(403, -1, 'Unauthorized or session expired') + try: + if session_type not in ['person', 'group']: + return self.http_status(400, -1, 'session_type must be person or group') + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + if not websocket_adapter: + return self.http_status(404, -1, 'WebSocket adapter not found') + + messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type) + return self.success(data={'messages': messages}) + + except Exception as e: + logger.error(f'Failed to get embed messages: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + @self.route('//reset/', methods=['POST'], auth_type=group.AuthType.NONE) + async def reset_embed_session(bot_uuid: str, session_type: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + if not await self._verify_session_token(quart.request, bot_uuid): + return self.http_status(403, -1, 'Unauthorized or session expired') + try: + if session_type not in ['person', 'group']: + return self.http_status(400, -1, 'session_type must be person or group') + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + if not websocket_adapter: + return self.http_status(404, -1, 'WebSocket adapter not found') + + websocket_adapter.reset_session(pipeline_uuid, session_type) + return self.success(data={'message': 'Session reset successfully'}) + + except Exception as e: + logger.error(f'Failed to reset embed session: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + @self.route('//feedback', methods=['POST'], auth_type=group.AuthType.NONE) + async def submit_feedback(bot_uuid: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + if not await self._verify_session_token(quart.request, bot_uuid): + return self.http_status(403, -1, 'Unauthorized or session expired') + try: + data = await quart.request.get_json() + message_id = data.get('message_id', '') + feedback_type = data.get('feedback_type') + + if feedback_type not in (1, 2, 3): + return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)') + + feedback_id = f'embed_{uuid.uuid4().hex[:12]}' + + await self.ap.monitoring_service.record_feedback( + feedback_id=feedback_id, + feedback_type=feedback_type, + bot_id=runtime_bot.bot_entity.uuid, + bot_name=runtime_bot.bot_entity.name or bot_uuid, + pipeline_id=pipeline_uuid, + message_id=str(message_id), + platform='web_page_bot', + ) + + return self.success(data={'feedback_id': feedback_id}) + + except Exception as e: + logger.error(f'Failed to record feedback: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + # -- Embed WebSocket endpoint ---------------------------------------- + + @self.quart_app.websocket(self.path + '//ws/connect') + async def embed_websocket_connect(bot_uuid: str): + """WebSocket connection for embed widget, keyed by bot_uuid.""" + if not _is_valid_uuid(bot_uuid): + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'})) + return + + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'})) + return + + session_type = quart.websocket.args.get('session_type', 'person') + if session_type not in ['person', 'group']: + await quart.websocket.send( + json.dumps({'type': 'error', 'message': 'session_type must be person or group'}) + ) + return + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + if not websocket_adapter: + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) + return + + try: + connection = await ws_connection_manager.add_connection( + websocket=quart.websocket._get_current_object(), + pipeline_uuid=pipeline_uuid, + session_type=session_type, + metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')}, + ) + + await quart.websocket.send( + json.dumps( + { + 'type': 'connected', + 'connection_id': connection.connection_id, + 'bot_uuid': bot_uuid, + 'session_type': session_type, + 'timestamp': connection.created_at.isoformat(), + } + ) + ) + + logger.debug( + f'Embed WebSocket connected: {connection.connection_id} ' + f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})' + ) + + receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot)) + send_task = asyncio.create_task(self._handle_send(connection)) + + try: + await asyncio.gather(receive_task, send_task) + except Exception as e: + logger.error(f'Embed WebSocket task error: {e}') + finally: + await ws_connection_manager.remove_connection(connection.connection_id) + + except Exception as e: + logger.error(f'Embed WebSocket connection error: {e}', exc_info=True) + try: + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'})) + except Exception: + pass + + # -- WebSocket receive/send helpers -------------------------------------- + + async def _handle_receive(self, connection, websocket_adapter, owner_bot): + try: + while connection.is_active: + message = await quart.websocket.receive() + await ws_connection_manager.update_activity(connection.connection_id) + + try: + data = json.loads(message) + message_type = data.get('type', 'message') + + if message_type == 'ping': + await connection.send_queue.put( + {'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()} + ) + elif message_type == 'message': + await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot) + elif message_type == 'disconnect': + break + + except json.JSONDecodeError: + await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'}) + + except Exception as e: + logger.error(f'Embed receive error: {e}', exc_info=True) + finally: + connection.is_active = False + + async def _handle_send(self, connection): + try: + while connection.is_active: + try: + message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0) + await quart.websocket.send(json.dumps(message)) + except asyncio.TimeoutError: + continue + except Exception as e: + logger.error(f'Embed send error: {e}', exc_info=True) + finally: + connection.is_active = False diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py new file mode 100644 index 0000000..2e45add --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import quart + +from ... import group + + +@group.group_class('pipelines', '/api/v1/pipelines') +class PipelinesRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + if quart.request.method == 'GET': + sort_by = quart.request.args.get('sort_by', 'created_at') + sort_order = quart.request.args.get('sort_order', 'DESC') + return self.success( + data={'pipelines': await self.ap.pipeline_service.get_pipelines(sort_by, sort_order)} + ) + elif quart.request.method == 'POST': + json_data = await quart.request.json + + pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data) + + return self.success(data={'uuid': pipeline_uuid}) + + @self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()}) + + @self.route( + '/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def _(pipeline_uuid: str) -> str: + if quart.request.method == 'GET': + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) + + if pipeline is None: + return self.http_status(404, -1, 'pipeline not found') + + return self.success(data={'pipeline': pipeline}) + elif quart.request.method == 'PUT': + json_data = await quart.request.json + + await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data) + + return self.success() + elif quart.request.method == 'DELETE': + await self.ap.pipeline_service.delete_pipeline(pipeline_uuid) + + return self.success() + + @self.route('//copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(pipeline_uuid: str) -> str: + try: + new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid) + return self.success(data={'uuid': new_uuid}) + except ValueError as e: + return self.http_status(404, -1, str(e)) + + @self.route( + '//extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def _(pipeline_uuid: str) -> str: + if quart.request.method == 'GET': + # Get current extensions and available plugins + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) + if pipeline is None: + return self.http_status(404, -1, 'pipeline not found') + + # Only include plugins with pipeline-related components (Command, EventListener, Tool) + # Plugins that only have KnowledgeEngine components are not suitable for pipeline extensions + pipeline_component_kinds = ['Command', 'EventListener', 'Tool'] + plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds) + mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) + + # Get available skills + available_skills = await self.ap.skill_service.list_skills() + + extensions_prefs = pipeline.get('extensions_preferences', {}) + return self.success( + data={ + 'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True), + 'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True), + 'enable_all_skills': extensions_prefs.get('enable_all_skills', True), + 'bound_plugins': extensions_prefs.get('plugins', []), + 'available_plugins': plugins, + 'bound_mcp_servers': extensions_prefs.get('mcp_servers', []), + 'available_mcp_servers': mcp_servers, + 'bound_mcp_resources': extensions_prefs.get('mcp_resources', []), + 'mcp_resource_agent_read_enabled': extensions_prefs.get( + 'mcp_resource_agent_read_enabled', True + ), + 'bound_skills': extensions_prefs.get('skills', []), + 'available_skills': available_skills, + } + ) + elif quart.request.method == 'PUT': + # Update bound plugins and MCP servers for this pipeline + json_data = await quart.request.json + enable_all_plugins = json_data.get('enable_all_plugins', True) + enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True) + enable_all_skills = json_data.get('enable_all_skills', True) + bound_plugins = json_data.get('bound_plugins', []) + bound_mcp_servers = json_data.get('bound_mcp_servers', []) + bound_skills = json_data.get('bound_skills', []) + bound_mcp_resources = json_data.get('bound_mcp_resources') + mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled') + + await self.ap.pipeline_service.update_pipeline_extensions( + pipeline_uuid, + bound_plugins, + bound_mcp_servers, + enable_all_plugins, + enable_all_mcp_servers, + bound_skills=bound_skills, + enable_all_skills=enable_all_skills, + bound_mcp_resources=bound_mcp_resources, + mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled, + ) + + return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py new file mode 100644 index 0000000..ebe46b8 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py @@ -0,0 +1,253 @@ +"""WebSocket聊天路由 - 支持双向实时通信""" + +import asyncio +import datetime +import json +import logging + +import quart + +from ... import group +from ......platform.sources.websocket_manager import ws_connection_manager + +logger = logging.getLogger(__name__) + + +@group.group_class('websocket_chat', '/api/v1/pipelines//ws') +class WebSocketChatRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + # 直接使用 quart_app 注册 WebSocket 路由 + @self.quart_app.websocket(self.path + '/connect') + async def websocket_connect(pipeline_uuid: str): + """ + 建立WebSocket连接 + + URL参数: + - pipeline_uuid: 流水线UUID + - session_type: 会话类型 (person/group) + """ + try: + # 获取参数 - 在WebSocket上下文中使用 quart.websocket.args + session_type = quart.websocket.args.get('session_type', 'person') + + if session_type not in ['person', 'group']: + await quart.websocket.send( + json.dumps({'type': 'error', 'message': 'session_type must be person or group'}) + ) + return + + # 获取WebSocket适配器 + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + + if not websocket_adapter: + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) + return + + # Dashboard pipeline-debug sessions must always run under the + # built-in websocket_proxy_bot identity. We deliberately do NOT + # resolve a web_page_bot owner here — even if one is bound to + # the same pipeline, debug requests must not be attributed to + # it. The embed widget path (`/api/v1/embed//ws/connect`) + # is the one that carries the page-bot identity. + + # 注册连接 + connection = await ws_connection_manager.add_connection( + websocket=quart.websocket._get_current_object(), + pipeline_uuid=pipeline_uuid, + session_type=session_type, + metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')}, + ) + + # 发送连接成功消息 + await quart.websocket.send( + json.dumps( + { + 'type': 'connected', + 'connection_id': connection.connection_id, + 'pipeline_uuid': pipeline_uuid, + 'session_type': session_type, + 'timestamp': connection.created_at.isoformat(), + } + ) + ) + + logger.debug( + f'WebSocket connection established: {connection.connection_id} ' + f'(pipeline={pipeline_uuid}, session_type={session_type})' + ) + + # 创建接收和发送任务 + receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter)) + send_task = asyncio.create_task(self._handle_send(connection)) + + # 等待任务完成 + try: + await asyncio.gather(receive_task, send_task) + except Exception as e: + logger.error(f'WebSocket task execution error: {e}') + finally: + # 清理连接 + await ws_connection_manager.remove_connection(connection.connection_id) + logger.debug(f'WebSocket connection cleaned: {connection.connection_id}') + + except Exception as e: + logger.error(f'WebSocket connection error: {e}', exc_info=True) + try: + await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)})) + except: + pass + + @self.route('/messages/', methods=['GET']) + async def get_messages(pipeline_uuid: str, session_type: str) -> str: + """获取消息历史""" + try: + if session_type not in ['person', 'group']: + return self.http_status(400, -1, 'session_type must be person or group') + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + + if not websocket_adapter: + return self.http_status(404, -1, 'WebSocket adapter not found') + + messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type) + + return self.success(data={'messages': messages}) + + except Exception as e: + return self.http_status(500, -1, f'Internal server error: {str(e)}') + + @self.route('/reset/', methods=['POST']) + async def reset_session(pipeline_uuid: str, session_type: str) -> str: + """重置会话""" + try: + if session_type not in ['person', 'group']: + return self.http_status(400, -1, 'session_type must be person or group') + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + + if not websocket_adapter: + return self.http_status(404, -1, 'WebSocket adapter not found') + + websocket_adapter.reset_session(pipeline_uuid, session_type) + + return self.success(data={'message': 'Session reset successfully'}) + + except Exception as e: + return self.http_status(500, -1, f'Internal server error: {str(e)}') + + @self.route('/connections', methods=['GET']) + async def get_connections(pipeline_uuid: str) -> str: + """获取当前连接统计""" + try: + stats = ws_connection_manager.get_stats() + connections = await ws_connection_manager.get_connections_by_pipeline(pipeline_uuid) + + return self.success( + data={ + 'stats': stats, + 'connections': [ + { + 'connection_id': conn.connection_id, + 'session_type': conn.session_type, + 'created_at': conn.created_at.isoformat(), + 'last_active': conn.last_active.isoformat(), + 'is_active': conn.is_active, + } + for conn in connections + ], + } + ) + + except Exception as e: + return self.http_status(500, -1, f'Internal server error: {str(e)}') + + @self.route('/broadcast', methods=['POST']) + async def broadcast_message(pipeline_uuid: str) -> str: + """向所有连接广播消息(后端主动推送)""" + try: + data = await quart.request.get_json() + message = data.get('message') + + if not message: + return self.http_status(400, -1, 'message is required') + + # 广播消息 + broadcast_data = { + 'type': 'broadcast', + 'message': message, + 'timestamp': datetime.datetime.now().isoformat(), + } + + await ws_connection_manager.broadcast_to_pipeline(pipeline_uuid, broadcast_data) + + return self.success(data={'message': 'Broadcast sent successfully'}) + + except Exception as e: + return self.http_status(500, -1, f'Internal server error: {str(e)}') + + async def _handle_receive(self, connection, websocket_adapter): + """处理接收消息的任务""" + try: + while connection.is_active: + # 接收消息 + message = await quart.websocket.receive() + + # 更新活跃时间 + await ws_connection_manager.update_activity(connection.connection_id) + + try: + data = json.loads(message) + message_type = data.get('type', 'message') + + if message_type == 'ping': + # 心跳响应 + await connection.send_queue.put( + {'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()} + ) + + elif message_type == 'message': + # 处理用户消息 + logger.debug(f'收到消息: {data} from {connection.connection_id}') + + # 处理消息(不等待响应,响应会通过broadcast异步发送) + # owner_bot is intentionally NOT passed: the dashboard + # debug WebSocket must always run under the proxy bot, + # never under a coincidentally-bound web_page_bot. + await websocket_adapter.handle_websocket_message(connection, data) + + elif message_type == 'disconnect': + # 客户端主动断开 + logger.debug(f'Client disconnected: {connection.connection_id}') + break + + else: + logger.warning(f'Unknown message type: {message_type}') + + except json.JSONDecodeError: + logger.error(f'Invalid JSON message: {message}') + await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'}) + + except Exception as e: + logger.error(f'Receive message error: {e}', exc_info=True) + finally: + connection.is_active = False + + async def _handle_send(self, connection): + """处理发送消息的任务""" + try: + while connection.is_active: + # 从队列获取消息 + try: + message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0) + + # 发送消息 + await quart.websocket.send(json.dumps(message)) + + except asyncio.TimeoutError: + # 超时继续循环 + continue + + except Exception as e: + logger.error(f'Send message error: {e}', exc_info=True) + finally: + connection.is_active = False diff --git a/src/langbot/pkg/api/http/controller/groups/platform/__init__.py b/src/langbot/pkg/api/http/controller/groups/platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py new file mode 100644 index 0000000..0e32f9d --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -0,0 +1,901 @@ +import quart +import mimetypes +import asyncio +from ... import group +from langbot.pkg.utils import importutil + + +def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str: + """Decrypt the AppSecret returned by the QQ Official QR binding endpoint. + + The base64 payload is laid out as `nonce (12 B) | ciphertext | tag (16 B)`. + `key` is the 32-byte AES-256 key locally generated when the bind task + was created and submitted as `key` to `q.qq.com/lite/create_bind_task`. + """ + import base64 + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + try: + raw = base64.b64decode(encrypted_b64) + except Exception as exc: + raise ValueError('Malformed encrypted credential') from exc + if len(key) != 32 or len(raw) <= 28: + raise ValueError('Invalid encrypted credential layout') + nonce, ciphertext, tag = raw[:12], raw[12:-16], raw[-16:] + try: + return AESGCM(key).decrypt(nonce, ciphertext + tag, None).decode('utf-8') + except Exception as exc: + raise ValueError('Failed to decrypt credential') from exc + + +@group.group_class('adapters', '/api/v1/platform/adapters') +class AdaptersRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET']) + async def _() -> str: + return self.success(data={'adapters': self.ap.platform_mgr.get_available_adapters_info()}) + + @self.route('/', methods=['GET']) + async def _(adapter_name: str) -> str: + adapter_info = self.ap.platform_mgr.get_available_adapter_info_by_name(adapter_name) + + if adapter_info is None: + return self.http_status(404, -1, 'adapter not found') + + return self.success(data={'adapter': adapter_info}) + + @self.route('//icon', methods=['GET'], auth_type=group.AuthType.NONE) + async def _(adapter_name: str) -> quart.Response: + adapter_manifest = self.ap.platform_mgr.get_available_adapter_manifest_by_name(adapter_name) + + if adapter_manifest is None: + return self.http_status(404, -1, 'adapter not found') + + icon_path = adapter_manifest.icon_rel_path + + if icon_path is None: + return self.http_status(404, -1, 'icon not found') + + return quart.Response( + importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] + ) + + @self.route('/dingtalk/human-input-card-template', methods=['GET'], auth_type=group.AuthType.NONE) + async def _() -> quart.Response: + filename = 'dingtalk_human_input_card.json' + response = quart.Response( + importutil.read_resource_file_bytes(f'templates/{filename}'), mimetype='application/json' + ) + response.headers['Content-Disposition'] = f'attachment; filename={filename}' + return response + + # In-memory session store for active registrations + _create_app_sessions: dict = {} + _SESSION_TTL = 900 # 15 minutes + + def _cleanup_expired_sessions(): + """Remove sessions that have exceeded their TTL.""" + import time + + now = time.time() + expired = [sid for sid, s in _create_app_sessions.items() if now - s.get('created_at', 0) > _SESSION_TTL] + for sid in expired: + session = _create_app_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/lark/create-app', methods=['POST']) + async def _() -> str: + """Start Feishu one-click app registration. Returns session_id + QR code URL.""" + import uuid + import time + import lark_oapi as lark + from lark_oapi.scene.registration.errors import AppAccessDeniedError, AppExpiredError + + _cleanup_expired_sessions() + + session_id = str(uuid.uuid4()) + loop = asyncio.get_running_loop() + + session = { + 'status': 'pending', + 'qr_url': None, + 'expire_at': None, + 'app_id': None, + 'app_secret': None, + 'error': None, + 'created_at': time.time(), + } + _create_app_sessions[session_id] = session + + def on_qr_code(info): + # May be called from a background thread by the SDK; + # use call_soon_threadsafe to safely update session state. + def _update(): + session['qr_url'] = info['url'] + session['expire_at'] = time.time() + 600 # 10 minutes + session['status'] = 'waiting' + + loop.call_soon_threadsafe(_update) + + async def run_registration(): + try: + result = await lark.aregister_app( + on_qr_code=on_qr_code, + source='langbot', + ) + session['status'] = 'success' + session['app_id'] = result['client_id'] + session['app_secret'] = result['client_secret'] + except AppAccessDeniedError: + session['status'] = 'error' + session['error'] = 'User denied authorization' + except AppExpiredError: + session['status'] = 'error' + session['error'] = 'QR code expired' + except Exception as e: + session['status'] = 'error' + session['error'] = str(e) + + task = asyncio.create_task(run_registration()) + session['task'] = task + + # Wait for QR code to be ready (max 10 seconds) + for _ in range(20): + if session['qr_url']: + break + await asyncio.sleep(0.5) + + if not session['qr_url']: + task.cancel() + session['status'] = 'error' + session['error'] = 'Timeout waiting for QR code' + return self.http_status(504, -1, 'Timeout waiting for QR code') + + return self.success( + data={ + 'session_id': session_id, + 'qr_url': session['qr_url'], + 'expire_at': session['expire_at'], + } + ) + + @self.route('/lark/create-app/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll registration status.""" + session = _create_app_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = {'status': session['status']} + + if session['status'] == 'success': + data['app_id'] = session['app_id'] + data['app_secret'] = session['app_secret'] + _create_app_sessions.pop(session_id, None) + elif session['status'] == 'error': + data['error'] = session['error'] + _create_app_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/lark/create-app/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a registration session.""" + session = _create_app_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) + + # ----------------------------------------------------------------------- + # WeChat QR Code Login + # ----------------------------------------------------------------------- + + _weixin_login_sessions: dict = {} + _WEIXIN_SESSION_TTL = 600 # 10 minutes (3 retries × 3 min QR validity) + + def _cleanup_expired_weixin_sessions(): + import time + + now = time.time() + expired = [ + sid for sid, s in _weixin_login_sessions.items() if now - s.get('created_at', 0) > _WEIXIN_SESSION_TTL + ] + for sid in expired: + session = _weixin_login_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/weixin/login', methods=['POST']) + async def _() -> str: + """Start WeChat QR code login. Returns session_id + QR code data URL.""" + import uuid + import time + + from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL + + _cleanup_expired_weixin_sessions() + + session_id = str(uuid.uuid4()) + loop = asyncio.get_running_loop() + + session = { + 'status': 'pending', + 'qr_data_url': None, + 'expire_at': None, + 'token': None, + 'base_url': None, + 'account_id': None, + 'error': None, + 'created_at': time.time(), + } + _weixin_login_sessions[session_id] = session + + client = OpenClawWeixinClient( + base_url=DEFAULT_BASE_URL, + token='', + ) + + async def run_login(): + try: + + def on_qrcode(qr_data_url: str, _qr_url: str): + def _update(): + session['qr_data_url'] = qr_data_url + session['expire_at'] = time.time() + 180 + session['status'] = 'waiting' + + loop.call_soon_threadsafe(_update) + + result = await client.login( + max_retries=1, + poll_timeout_ms=180_000, + on_qrcode=on_qrcode, + ) + session['status'] = 'success' + session['token'] = result.token + session['base_url'] = result.base_url + session['account_id'] = result.account_id + except Exception as e: + error_message = str(e) + if 'expired' in error_message.lower() or 'max retries exceeded' in error_message.lower(): + session['status'] = 'expired' + session['error'] = 'QR code expired' + else: + session['status'] = 'error' + session['error'] = error_message + finally: + await client.close() + + task = asyncio.create_task(run_login()) + session['task'] = task + + # Wait for QR code to be ready (max 10 seconds) + for _ in range(20): + if session['qr_data_url']: + break + await asyncio.sleep(0.5) + + if not session['qr_data_url']: + task.cancel() + session['status'] = 'error' + session['error'] = 'Timeout waiting for QR code' + return self.http_status(504, -1, 'Timeout waiting for QR code') + + return self.success( + data={ + 'session_id': session_id, + 'qr_data_url': session['qr_data_url'], + 'expire_at': session['expire_at'], + } + ) + + @self.route('/weixin/login/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll WeChat login status.""" + session = _weixin_login_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = { + 'status': session['status'], + 'qr_data_url': session['qr_data_url'], + 'expire_at': session['expire_at'], + } + + if session['status'] == 'success': + data['token'] = session['token'] + data['base_url'] = session['base_url'] + data['account_id'] = session['account_id'] + _weixin_login_sessions.pop(session_id, None) + elif session['status'] == 'error': + data['error'] = session['error'] + _weixin_login_sessions.pop(session_id, None) + elif session['status'] == 'expired': + data['error'] = session['error'] + _weixin_login_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/weixin/login/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a WeChat login session.""" + session = _weixin_login_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) + + # ----------------------------------------------------------------------- + # DingTalk Device Flow QR Code Login + # ----------------------------------------------------------------------- + + _dingtalk_sessions: dict = {} + _DINGTALK_SESSION_TTL = 600 # 10 minutes (QR code validity window) + + def _cleanup_expired_dingtalk_sessions(): + import time + + now = time.time() + expired = [ + sid for sid, s in _dingtalk_sessions.items() if now - s.get('created_at', 0) > _DINGTALK_SESSION_TTL + ] + for sid in expired: + session = _dingtalk_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/dingtalk/create-app', methods=['POST']) + async def _() -> str: + """Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL.""" + import uuid + import time + import aiohttp + + DINGTALK_BASE_URL = 'https://oapi.dingtalk.com' + + _cleanup_expired_dingtalk_sessions() + + session_id = str(uuid.uuid4()) + + session = { + 'status': 'pending', + 'qr_url': None, + 'expire_at': None, + 'client_id': None, + 'client_secret': None, + 'error': None, + 'created_at': time.time(), + 'device_code': None, + 'interval': 5, + } + _dingtalk_sessions[session_id] = session + + async def run_device_flow(): + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as http: + # Step 1: Init — get nonce + async with http.post( + f'{DINGTALK_BASE_URL}/app/registration/init', + json={'source': 'langbot'}, + ) as resp: + try: + data = await resp.json() + except (aiohttp.ContentTypeError, ValueError): + session['status'] = 'error' + session['error'] = 'Invalid response from DingTalk service' + return + if data.get('errcode', -1) != 0: + session['status'] = 'error' + session['error'] = data.get('errmsg', 'Failed to init') + return + nonce = data['nonce'] + + # Step 2: Begin — get device_code + QR URL + async with http.post( + f'{DINGTALK_BASE_URL}/app/registration/begin', + json={'nonce': nonce}, + ) as resp: + try: + data = await resp.json() + except (aiohttp.ContentTypeError, ValueError): + session['status'] = 'error' + session['error'] = 'Invalid response from DingTalk service' + return + if data.get('errcode', -1) != 0: + session['status'] = 'error' + session['error'] = data.get('errmsg', 'Failed to begin authorization') + return + + device_code = data['device_code'] + verification_uri_complete = data.get('verification_uri_complete', '') + expires_in = data.get('expires_in', 7200) + interval = data.get('interval', 5) + + session['device_code'] = device_code + session['interval'] = interval + session['qr_url'] = verification_uri_complete + session['expire_at'] = time.time() + 600 # QR code valid for ~10 min + session['status'] = 'waiting' + + # Step 3: Poll for authorization result + deadline = time.time() + expires_in + while time.time() < deadline: + await asyncio.sleep(interval) + + async with http.post( + f'{DINGTALK_BASE_URL}/app/registration/poll', + json={'device_code': device_code}, + ) as poll_resp: + try: + poll_data = await poll_resp.json() + except (aiohttp.ContentTypeError, ValueError): + continue + + if poll_data.get('errcode', -1) != 0: + session['status'] = 'error' + session['error'] = poll_data.get('errmsg', 'Poll failed') + return + + status = poll_data.get('status', '') + + if status == 'SUCCESS': + session['status'] = 'success' + session['client_id'] = poll_data.get('client_id', '') + session['client_secret'] = poll_data.get('client_secret', '') + return + elif status == 'FAIL': + session['status'] = 'error' + session['error'] = poll_data.get('fail_reason', 'Authorization failed') + return + elif status == 'EXPIRED': + session['status'] = 'error' + session['error'] = 'QR code expired' + return + # status == 'WAITING': continue polling + + # Timeout + session['status'] = 'error' + session['error'] = 'QR code expired' + + except asyncio.CancelledError: + return + except Exception as e: + session['status'] = 'error' + session['error'] = str(e) + + task = asyncio.create_task(run_device_flow()) + session['task'] = task + + # Wait for QR code to be ready (max 10 seconds) + for _ in range(20): + if session['qr_url'] or session['error']: + break + await asyncio.sleep(0.5) + + if session['error']: + task.cancel() + return self.http_status(502, -1, session['error']) + + if not session['qr_url']: + task.cancel() + session['status'] = 'error' + session['error'] = 'Timeout waiting for QR code' + return self.http_status(504, -1, 'Timeout waiting for QR code') + + return self.success( + data={ + 'session_id': session_id, + 'qr_url': session['qr_url'], + 'expire_at': session['expire_at'], + } + ) + + @self.route('/dingtalk/create-app/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll DingTalk Device Flow status.""" + _cleanup_expired_dingtalk_sessions() + session = _dingtalk_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = {'status': session['status']} + + if session['status'] == 'success': + data['client_id'] = session['client_id'] + data['client_secret'] = session['client_secret'] + _dingtalk_sessions.pop(session_id, None) + elif session['status'] == 'error': + data['error'] = session['error'] + _dingtalk_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/dingtalk/create-app/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a DingTalk Device Flow session.""" + session = _dingtalk_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) + + # ----------------------------------------------------------------------- + # WeComBot QR Code One-Click Create + # ----------------------------------------------------------------------- + + _wecombot_sessions: dict = {} + _WECOMBOT_SESSION_TTL = 300 # 5 minutes (WeCom QR validity window) + + def _cleanup_expired_wecombot_sessions(): + import time + + now = time.time() + expired = [ + sid for sid, s in _wecombot_sessions.items() if now - s.get('created_at', 0) > _WECOMBOT_SESSION_TTL + ] + for sid in expired: + session = _wecombot_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/wecombot/create-bot', methods=['POST']) + async def _() -> str: + """Start WeComBot one-click creation via QR code. Returns session_id + QR code URL.""" + import uuid + import time + import aiohttp + + WECOM_QC_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate' + WECOM_QC_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result' + + _cleanup_expired_wecombot_sessions() + + session_id = str(uuid.uuid4()) + + session = { + 'status': 'pending', + 'qr_url': None, + 'expire_at': None, + 'botid': None, + 'secret': None, + 'error': None, + 'created_at': time.time(), + 'scode': None, + 'task': None, + } + _wecombot_sessions[session_id] = session + + async def run_qr_flow(): + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as http: + # Step 1: Generate QR code + async with http.get( + f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0', + ) as resp: + try: + data = await resp.json() + except (aiohttp.ContentTypeError, ValueError): + session['status'] = 'error' + session['error'] = 'Invalid response from WeCom service' + return + if not data.get('data', {}).get('scode') or not data.get('data', {}).get('auth_url'): + session['status'] = 'error' + session['error'] = data.get('errmsg', 'Failed to generate QR code') + return + + scode = data['data']['scode'] + auth_url = data['data']['auth_url'] + + session['scode'] = scode + session['qr_url'] = auth_url + session['expire_at'] = time.time() + _WECOMBOT_SESSION_TTL + session['status'] = 'waiting' + + # Step 2: Poll for scan result + deadline = time.time() + _WECOMBOT_SESSION_TTL + while time.time() < deadline: + await asyncio.sleep(3) + + async with http.get( + f'{WECOM_QC_QUERY_URL}?scode={scode}', + ) as poll_resp: + try: + poll_data = await poll_resp.json() + except (aiohttp.ContentTypeError, ValueError): + continue + + status = poll_data.get('data', {}).get('status', '') + if status == 'success': + bot_info = poll_data.get('data', {}).get('bot_info', {}) + if bot_info.get('botid') and bot_info.get('secret'): + session['status'] = 'success' + session['botid'] = bot_info['botid'] + session['secret'] = bot_info['secret'] + return + else: + session['status'] = 'error' + session['error'] = 'Scan succeeded but bot info is incomplete' + return + + # Timeout + session['status'] = 'error' + session['error'] = 'QR code expired' + + except asyncio.CancelledError: + return + except Exception as e: + session['status'] = 'error' + session['error'] = str(e) + + task = asyncio.create_task(run_qr_flow()) + session['task'] = task + + # Wait for QR code to be ready (max 10 seconds) + for _ in range(20): + if session['qr_url'] or session['error']: + break + await asyncio.sleep(0.5) + + if session['error']: + task.cancel() + return self.http_status(502, -1, session['error']) + + if not session['qr_url']: + task.cancel() + session['status'] = 'error' + session['error'] = 'Timeout waiting for QR code' + return self.http_status(504, -1, 'Timeout waiting for QR code') + + return self.success( + data={ + 'session_id': session_id, + 'qr_url': session['qr_url'], + 'expire_at': session['expire_at'], + } + ) + + @self.route('/wecombot/create-bot/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll WeComBot creation status.""" + _cleanup_expired_wecombot_sessions() + session = _wecombot_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = {'status': session['status']} + + if session['status'] == 'success': + data['botid'] = session['botid'] + data['secret'] = session['secret'] + _wecombot_sessions.pop(session_id, None) + elif session['status'] == 'error': + data['error'] = session['error'] + _wecombot_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/wecombot/create-bot/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a WeComBot creation session.""" + session = _wecombot_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) + + # ----------------------------------------------------------------------- + # QQ Official QR Binding + # ----------------------------------------------------------------------- + + _qqofficial_sessions: dict = {} + _QQOFFICIAL_SESSION_TTL = 300 # 5 minutes (QQ bind QR validity window) + + def _cleanup_expired_qqofficial_sessions(): + import time + + now = time.time() + expired = [ + sid for sid, s in _qqofficial_sessions.items() if now - s.get('created_at', 0) > _QQOFFICIAL_SESSION_TTL + ] + for sid in expired: + session = _qqofficial_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/qqofficial/bind', methods=['POST']) + async def _() -> str: + """Start QQ Official QR binding. Returns session_id + QR URL. + + Flow: generate a local AES-256 key, register it with + `q.qq.com/lite/create_bind_task`, then poll + `q.qq.com/lite/poll_bind_result` until the user authorizes the + bind inside the QQ Bot Assistant on mobile QQ. The encrypted + AppSecret returned by the poll endpoint is decrypted with the + same key. The key never leaves this process. + """ + import uuid + import time + import secrets + import base64 + import aiohttp + + QQ_BIND_BASE = 'https://q.qq.com' + _cleanup_expired_qqofficial_sessions() + + bind_key_bytes = secrets.token_bytes(32) + bind_key = base64.b64encode(bind_key_bytes).decode('ascii') + + session_id = str(uuid.uuid4()) + session = { + 'status': 'pending', + 'qr_url': None, + 'expire_at': None, + 'appid': None, + 'secret': None, + 'user_openid': None, + 'error': None, + 'created_at': time.time(), + 'task_id': None, + 'bind_key_bytes': bind_key_bytes, + 'interval': 2, + } + _qqofficial_sessions[session_id] = session + + async def run_qr_binding(): + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as http: + # Step 1: create_bind_task — register our AES key, get task_id + async with http.post( + f'{QQ_BIND_BASE}/lite/create_bind_task', + json={'key': bind_key}, + headers={'Accept': 'application/json'}, + ) as resp: + try: + data = await resp.json(content_type=None) + except (aiohttp.ContentTypeError, ValueError): + session['status'] = 'error' + session['error'] = 'Invalid response from QQ bind service' + return + if int(data.get('retcode', -1)) != 0: + session['status'] = 'error' + session['error'] = ( + data.get('msg') or data.get('message') or 'Failed to create bind task' + ) + return + task_id = str((data.get('data') or {}).get('task_id') or '').strip() + if not task_id: + session['status'] = 'error' + session['error'] = 'Missing task_id in QQ response' + return + + # The QR encodes a URL that mobile QQ opens inside the QQ Bot Assistant. + # `source=langbot` is a courtesy attribution parameter so Tencent + # can see LangBot adoption metrics, matching the convention used by + # other third-party integrations (e.g. hermes-agent uses `source=hermes`). + qr_url = f'{QQ_BIND_BASE}/qqbot/openclaw/connect.html?task_id={task_id}&_wv=2&source=langbot' + session['task_id'] = task_id + session['qr_url'] = qr_url + session['expire_at'] = time.time() + _QQOFFICIAL_SESSION_TTL + session['status'] = 'waiting' + + # Step 2: poll_bind_result until completed (status=2) or expired (3). + deadline = time.time() + _QQOFFICIAL_SESSION_TTL + while time.time() < deadline: + await asyncio.sleep(session['interval']) + + async with http.post( + f'{QQ_BIND_BASE}/lite/poll_bind_result', + json={'task_id': task_id}, + headers={'Accept': 'application/json'}, + ) as poll_resp: + try: + poll_data = await poll_resp.json(content_type=None) + except (aiohttp.ContentTypeError, ValueError): + continue + + if int(poll_data.get('retcode', -1)) != 0: + session['status'] = 'error' + session['error'] = poll_data.get('msg') or poll_data.get('message') or 'Poll failed' + return + + payload = poll_data.get('data') or {} + try: + raw_status = int(payload.get('status', 0)) + except (TypeError, ValueError): + raw_status = 0 + + if raw_status == 2: + appid = str(payload.get('bot_appid') or '').strip() + encrypted = str(payload.get('bot_encrypt_secret') or '').strip() + if not appid or not encrypted: + session['status'] = 'error' + session['error'] = 'Incomplete credential payload' + return + try: + session['secret'] = _decrypt_qqofficial_secret( + encrypted, + bind_key_bytes, + ) + except ValueError as exc: + session['status'] = 'error' + session['error'] = str(exc) + return + session['appid'] = appid + # The scanner's OpenID is returned alongside the credentials — + # surfaced to the dashboard for audit / "bound by" display. + session['user_openid'] = str(payload.get('user_openid') or '').strip() or None + session['status'] = 'success' + return + + if raw_status == 3: + session['status'] = 'expired' + session['error'] = 'QR code expired' + return + # status 0 / 1: still pending, continue polling + + session['status'] = 'expired' + session['error'] = 'QR code expired' + + except asyncio.CancelledError: + return + except Exception as e: + session['status'] = 'error' + session['error'] = str(e) + + task = asyncio.create_task(run_qr_binding()) + session['task'] = task + + # Wait up to 10s for the QR URL to be ready before responding. + for _ in range(20): + if session['qr_url'] or session['error']: + break + await asyncio.sleep(0.5) + + if session['error']: + task.cancel() + return self.http_status(502, -1, session['error']) + + if not session['qr_url']: + task.cancel() + session['status'] = 'error' + session['error'] = 'Timeout waiting for QR code' + return self.http_status(504, -1, 'Timeout waiting for QR code') + + return self.success( + data={ + 'session_id': session_id, + 'qr_url': session['qr_url'], + 'expire_at': session['expire_at'], + } + ) + + @self.route('/qqofficial/bind/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll QQ Official QR binding status.""" + _cleanup_expired_qqofficial_sessions() + session = _qqofficial_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = {'status': session['status']} + + if session['status'] == 'success': + data['appid'] = session['appid'] + data['secret'] = session['secret'] + if session.get('user_openid'): + data['user_openid'] = session['user_openid'] + _qqofficial_sessions.pop(session_id, None) + elif session['status'] in ('error', 'expired'): + data['error'] = session['error'] + _qqofficial_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/qqofficial/bind/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a QQ Official QR binding session.""" + session = _qqofficial_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py new file mode 100644 index 0000000..e3a13b7 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -0,0 +1,90 @@ +import quart + +from ... import group + + +@group.group_class('bots', '/api/v1/platform/bots') +class BotsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + if quart.request.method == 'GET': + return self.success(data={'bots': await self.ap.bot_service.get_bots()}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + bot_uuid = await self.ap.bot_service.create_bot(json_data) + return self.success(data={'uuid': bot_uuid}) + + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(bot_uuid: str) -> str: + if quart.request.method == 'GET': + bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid) + if bot is None: + return self.http_status(404, -1, 'bot not found') + return self.success(data={'bot': bot}) + elif quart.request.method == 'PUT': + json_data = await quart.request.json + await self.ap.bot_service.update_bot(bot_uuid, json_data) + return self.success() + elif quart.request.method == 'DELETE': + await self.ap.bot_service.delete_bot(bot_uuid) + return self.success() + + @self.route('//logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(bot_uuid: str) -> str: + json_data = await quart.request.json + from_index = json_data.get('from_index', -1) + max_count = json_data.get('max_count', 10) + logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count) + return self.success(data={'logs': logs, 'total_count': total_count}) + + @self.route('//send_message', methods=['POST'], auth_type=group.AuthType.API_KEY) + async def _(bot_uuid: str) -> str: + json_data = await quart.request.json + target_type = json_data.get('target_type') + target_id = json_data.get('target_id') + message_chain_data = json_data.get('message_chain') + + if not target_type: + return self.http_status(400, -1, 'target_type is required') + if not target_id: + return self.http_status(400, -1, 'target_id is required') + if not message_chain_data: + return self.http_status(400, -1, 'message_chain is required') + if target_type not in ['person', 'group']: + return self.http_status(400, -1, 'target_type must be either "person" or "group"') + + try: + await self.ap.bot_service.send_message(bot_uuid, target_type, target_id, message_chain_data) + return self.success(data={'sent': True}) + except Exception as e: + import traceback + + traceback.print_exc() + return self.http_status(500, -1, f'Failed to send message: {str(e)}') + + # ============ Bot Admins ============ + + @self.route('//admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(bot_uuid: str) -> str: + if quart.request.method == 'GET': + admins = await self.ap.bot_service.get_bot_admins(bot_uuid) + return self.success(data={'admins': admins}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + launcher_type = json_data.get('launcher_type', '').strip() + launcher_id = str(json_data.get('launcher_id', '')).strip() + if not launcher_type or not launcher_id: + return self.http_status(400, -1, 'launcher_type and launcher_id are required') + try: + admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id) + return self.success(data={'id': admin_id}) + except Exception as e: + return self.http_status(409, -1, str(e)) + + @self.route( + '//admins/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def _(bot_uuid: str, admin_id: int) -> str: + await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id) + return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py new file mode 100644 index 0000000..c291c12 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -0,0 +1,674 @@ +from __future__ import annotations + +import base64 +import io +import quart +import re +import httpx +import uuid +import os +import zipfile +import yaml +from urllib.parse import urlparse +import posixpath +import sqlalchemy + +from .....core import taskmgr +from .....entity.persistence import plugin as persistence_plugin +from .. import group +from langbot_plugin.runtime.plugin.mgr import PluginInstallSource + +# Resolve the built-in page SDK JS from the langbot_plugin package +_PAGE_SDK_PATH = None +try: + import langbot_plugin.assets as _assets_pkg + + _candidate = os.path.join(os.path.dirname(_assets_pkg.__file__), 'langbot-page-sdk.js') + if os.path.exists(_candidate): + _PAGE_SDK_PATH = _candidate +except Exception: + pass + + +def _normalize_plugin_asset_path(filepath: str) -> str | None: + filepath = filepath.replace('\\', '/') + if filepath.startswith('/'): + return None + + normalized = posixpath.normpath(filepath) + if normalized == '.' or normalized.startswith('../') or normalized == '..': + return None + + if normalized.startswith('components/pages/'): + return normalized + + return f'assets/{normalized}' + + +def _get_request_origin() -> str: + """Return the public request origin, respecting reverse-proxy headers.""" + forwarded_proto = quart.request.headers.get('X-Forwarded-Proto', '').split(',')[0].strip() + forwarded_host = quart.request.headers.get('X-Forwarded-Host', '').split(',')[0].strip() + + scheme = forwarded_proto or quart.request.scheme + host = forwarded_host or quart.request.host + return f'{scheme}://{host}' + + +@group.group_class('plugins', '/api/v1/plugins') +class PluginsRouterGroup(group.RouterGroup): + @staticmethod + def _normalize_archive_path(path: str) -> str: + normalized = str(path or '').replace('\\', '/').strip('/') + return posixpath.normpath(normalized) if normalized else '' + + @classmethod + def _component_source_path(cls, entry) -> str: + if isinstance(entry, dict): + return cls._normalize_archive_path(entry.get('path') or '') + return cls._normalize_archive_path(str(entry or '')) + + @classmethod + def _count_component_configs(cls, component_config, archive_names: list[str]) -> int: + normalized_names = [cls._normalize_archive_path(name) for name in archive_names] + component_files: set[str] = set() + + if isinstance(component_config, list): + return len(component_config) + if not isinstance(component_config, dict): + return 1 if component_config else 0 + + for entry in component_config.get('fromFiles') or []: + source_path = cls._component_source_path(entry) + if source_path and source_path in normalized_names: + component_files.add(source_path) + + for entry in component_config.get('fromDirs') or []: + source_dir = cls._component_source_path(entry).rstrip('/') + if not source_dir: + continue + prefix = f'{source_dir}/' + for archive_name in normalized_names: + if not archive_name.startswith(prefix): + continue + if archive_name.lower().endswith(('.yaml', '.yml')): + component_files.add(archive_name) + + if component_files: + return len(component_files) + + return 1 if any(key in component_config for key in ('path', 'name', 'kind')) else 0 + + @classmethod + def _count_plugin_components(cls, components, archive_names: list[str]) -> dict[str, int]: + if not isinstance(components, dict): + return {} + + component_counts: dict[str, int] = {} + for kind, component_config in components.items(): + count = cls._count_component_configs(component_config, archive_names) + if count > 0: + component_counts[str(kind)] = count + return component_counts + + @staticmethod + def _parse_github_repo_url(repo_url: str) -> dict | None: + raw_url = str(repo_url or '').strip() + if not raw_url: + return None + + if not re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', raw_url): + raw_url = f'https://{raw_url}' + + parsed = urlparse(raw_url) + if parsed.netloc.lower() not in ('github.com', 'www.github.com'): + return None + + parts = [part for part in parsed.path.strip('/').split('/') if part] + if len(parts) < 2: + return None + + owner = parts[0] + repo = parts[1] + if repo.endswith('.git'): + repo = repo[:-4] + if not owner or not repo: + return None + + ref = '' + subdir = '' + if len(parts) >= 4 and parts[2] in ('tree', 'blob'): + ref = parts[3] + subdir = '/'.join(parts[4:]).strip('/') + + return { + 'owner': owner, + 'repo': repo, + 'ref': ref, + 'subdir': subdir, + } + + async def _check_extensions_limit(self) -> str | None: + """Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise.""" + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + max_extensions = limitation.get('max_extensions', -1) + if max_extensions >= 0: + plugins = await self.ap.plugin_connector.list_plugins() + mcp_servers = await self.ap.mcp_service.get_mcp_servers() + total_extensions = len(plugins) + len(mcp_servers) + if total_extensions >= max_extensions: + return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached') + return None + + async def initialize(self) -> None: + @self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE) + async def _() -> quart.Response: + """Serve the built-in LangBot page SDK JavaScript.""" + if _PAGE_SDK_PATH and os.path.exists(_PAGE_SDK_PATH): + with open(_PAGE_SDK_PATH, 'r') as f: + content = f.read() + return quart.Response(content, mimetype='application/javascript') + return quart.Response('// SDK not found', status=404, mimetype='application/javascript') + + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + plugins = await self.ap.plugin_connector.list_plugins() + + return self.success(data={'plugins': plugins}) + + @self.route('/debug-info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + """Get plugin debug information including debug URL and key""" + debug_info = await self.ap.plugin_connector.get_debug_info() + + # Get debug URL from config + plugin_config = self.ap.instance_config.data.get('plugin', {}) + debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401') + + return self.success( + data={ + 'debug_url': debug_url, + 'plugin_debug_key': debug_info.get('plugin_debug_key', ''), + } + ) + + @self.route( + '///upgrade', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def _(author: str, plugin_name: str) -> str: + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx), + kind='plugin-operation', + name=f'plugin-upgrade-{plugin_name}', + label=f'Upgrading plugin {plugin_name}', + context=ctx, + ) + return self.success(data={'task_id': wrapper.id}) + + @self.route( + '//', + methods=['GET', 'DELETE'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def _(author: str, plugin_name: str) -> str: + if quart.request.method == 'GET': + plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) + if plugin is None: + return self.http_status(404, -1, 'plugin not found') + return self.success(data={'plugin': plugin}) + elif quart.request.method == 'DELETE': + delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true' + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + self.ap.plugin_connector.delete_plugin( + author, plugin_name, delete_data=delete_data, task_context=ctx + ), + kind='plugin-operation', + name=f'plugin-remove-{plugin_name}', + label=f'Removing plugin {plugin_name}', + context=ctx, + ) + + return self.success(data={'task_id': wrapper.id}) + + @self.route( + '///config', + methods=['GET', 'PUT'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def _(author: str, plugin_name: str) -> quart.Response: + plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name) + if plugin is None: + return self.http_status(404, -1, 'plugin not found') + + if quart.request.method == 'GET': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_plugin.PluginSetting.config) + .where(persistence_plugin.PluginSetting.plugin_author == author) + .where(persistence_plugin.PluginSetting.plugin_name == plugin_name) + ) + persisted_config = result.scalar_one_or_none() + + config = persisted_config if persisted_config is not None else plugin['plugin_config'] + return self.success(data={'config': config}) + elif quart.request.method == 'PUT': + data = await quart.request.json + + await self.ap.plugin_connector.set_plugin_config(author, plugin_name, data) + + return self.success(data={}) + + @self.route( + '///readme', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def _(author: str, plugin_name: str) -> quart.Response: + language = quart.request.args.get('language', 'en') + readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language) + return self.success(data={'readme': readme}) + + @self.route( + '///logs', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def _(author: str, plugin_name: str) -> quart.Response: + try: + limit = int(quart.request.args.get('limit', 200)) + except (TypeError, ValueError): + limit = 200 + level = quart.request.args.get('level') or None + logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level) + return self.success(data={'logs': logs}) + + @self.route( + '///icon', + methods=['GET'], + auth_type=group.AuthType.NONE, + ) + async def _(author: str, plugin_name: str) -> quart.Response: + icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name) + icon_base64 = icon_data['plugin_icon_base64'] + mime_type = icon_data['mime_type'] + + icon_data = base64.b64decode(icon_base64) + + return quart.Response(icon_data, mimetype=mime_type) + + @self.route( + '///assets/', + methods=['GET'], + auth_type=group.AuthType.NONE, + ) + async def _(author: str, plugin_name: str, filepath: str) -> quart.Response: + asset_path = _normalize_plugin_asset_path(filepath) + if asset_path is None: + return quart.Response('Asset not found', status=404) + + asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path) + if not asset_data.get('asset_base64'): + return quart.Response('Asset not found', status=404) + asset_bytes = base64.b64decode(asset_data['asset_base64']) + mime_type = asset_data['mime_type'] + resp = quart.Response(asset_bytes, mimetype=mime_type) + # CSP for HTML pages served to sandboxed iframes (opaque origin). + # 'self' doesn't work in sandboxed iframes — use actual server origin. + if mime_type and mime_type.startswith('text/html'): + origin = _get_request_origin() + resp.headers['Content-Security-Policy'] = ( + f'default-src {origin}; ' + f"script-src {origin} 'unsafe-inline'; " + f"style-src {origin} 'unsafe-inline'; " + f'img-src {origin} data:; ' + f'connect-src {origin}; ' + "frame-src 'none'; " + "object-src 'none'" + ) + return resp + + @self.route( + '///page-api', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def _(author: str, plugin_name: str) -> str: + """Forward a page API request to the plugin.""" + data = await quart.request.json + if not isinstance(data, dict): + return self.http_status(400, -1, 'invalid request body') + + page_id = data.get('page_id', '') + endpoint = data.get('endpoint', '') + method = data.get('method', 'POST') + body = data.get('body') + if not isinstance(page_id, str) or not isinstance(endpoint, str) or not isinstance(method, str): + return self.http_status(400, -1, 'invalid page api request') + if not endpoint.startswith('/') or '..' in endpoint: + return self.http_status(400, -1, 'invalid endpoint') + + result = await self.ap.plugin_connector.handle_page_api( + author, plugin_name, page_id, endpoint, method.upper(), body + ) + if result.get('error'): + return self.http_status(400, -1, result['error']) + return self.success(data=result.get('data')) + + @self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + """Get releases from a GitHub repository URL""" + data = await quart.request.json + repo_url = data.get('repo_url', '') + + parsed_repo = self._parse_github_repo_url(repo_url) + if not parsed_repo: + return self.http_status(400, -1, 'Invalid GitHub repository URL') + + owner = parsed_repo['owner'] + repo = parsed_repo['repo'] + requested_ref = parsed_repo['ref'] + requested_subdir = parsed_repo['subdir'] + + try: + if requested_ref: + return self.success( + data={ + 'releases': [ + { + 'id': 0, + 'tag_name': requested_ref, + 'name': requested_ref, + 'published_at': '', + 'prerelease': False, + 'draft': False, + 'source_type': 'branch', + 'archive_url': f'https://api.github.com/repos/{owner}/{repo}/zipball/{requested_ref}', + } + ], + 'owner': owner, + 'repo': repo, + 'source_subdir': requested_subdir, + } + ) + + # Fetch releases from GitHub API + url = f'https://api.github.com/repos/{owner}/{repo}/releases' + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=10, + ) as client: + response = await client.get(url) + response.raise_for_status() + releases = response.json() + + # Format releases data for frontend + formatted_releases = [] + for release in releases: + formatted_releases.append( + { + 'id': release['id'], + 'tag_name': release['tag_name'], + 'name': release['name'], + 'published_at': release['published_at'], + 'prerelease': release['prerelease'], + 'draft': release['draft'], + } + ) + + return self.success( + data={ + 'releases': formatted_releases, + 'owner': owner, + 'repo': repo, + 'source_subdir': requested_subdir, + } + ) + except httpx.RequestError as e: + return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}') + + @self.route( + '/github/release-assets', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def _() -> str: + """Get assets from a specific GitHub release""" + data = await quart.request.json + owner = data.get('owner', '') + repo = data.get('repo', '') + release_id = data.get('release_id', '') + + if not all([owner, repo, release_id]): + return self.http_status(400, -1, 'Missing required parameters') + + try: + # Fetch release assets from GitHub API + url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}' + async with httpx.AsyncClient( + trust_env=True, + follow_redirects=True, + timeout=10, + ) as client: + response = await client.get( + url, + ) + response.raise_for_status() + release = response.json() + + # Format assets data for frontend + formatted_assets = [] + for asset in release.get('assets', []): + formatted_assets.append( + { + 'id': asset['id'], + 'name': asset['name'], + 'size': asset['size'], + 'download_url': asset['browser_download_url'], + 'content_type': asset['content_type'], + } + ) + + # add zipball as a downloadable asset + # formatted_assets.append( + # { + # "id": 0, + # "name": "Source code (zip)", + # "size": -1, + # "download_url": release["zipball_url"], + # "content_type": "application/zip", + # } + # ) + + return self.success(data={'assets': formatted_assets}) + except httpx.RequestError as e: + return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}') + + @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + """Install plugin from GitHub release asset""" + limit_error = await self._check_extensions_limit() + if limit_error is not None: + return limit_error + + data = await quart.request.json + asset_url = data.get('asset_url', '') + owner = data.get('owner', '') + repo = data.get('repo', '') + release_tag = data.get('release_tag', '') + + if not asset_url: + return self.http_status(400, -1, 'Missing asset_url parameter') + + ctx = taskmgr.TaskContext.new() + ctx.metadata['plugin_name'] = f'{owner}/{repo}' + ctx.metadata['install_source'] = 'github' + install_info = { + 'asset_url': asset_url, + 'owner': owner, + 'repo': repo, + 'release_tag': release_tag, + 'github_url': f'https://github.com/{owner}/{repo}', + } + + wrapper = self.ap.task_mgr.create_user_task( + self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx), + kind='plugin-operation', + name='plugin-install-github', + label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}', + context=ctx, + ) + + return self.success(data={'task_id': wrapper.id}) + + @self.route( + '/install/marketplace', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def _() -> str: + limit_error = await self._check_extensions_limit() + if limit_error is not None: + return limit_error + + data = await quart.request.json + + plugin_author = data.get('plugin_author', '') + plugin_name = data.get('plugin_name', '') + + ctx = taskmgr.TaskContext.new() + ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}' + ctx.metadata['install_source'] = 'marketplace' + wrapper = self.ap.task_mgr.create_user_task( + self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx), + kind='plugin-operation', + name='plugin-install-marketplace', + label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}', + context=ctx, + ) + + return self.success(data={'task_id': wrapper.id}) + + @self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + limit_error = await self._check_extensions_limit() + if limit_error is not None: + return limit_error + + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + + file_bytes = file.read() + + data = { + 'plugin_file': file_bytes, + } + + ctx = taskmgr.TaskContext.new() + ctx.metadata['plugin_name'] = file.filename or 'local plugin' + ctx.metadata['install_source'] = 'local' + wrapper = self.ap.task_mgr.create_user_task( + self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx), + kind='plugin-operation', + name='plugin-install-local', + label=f'Installing plugin from local {file.filename}', + context=ctx, + ) + + return self.success(data={'task_id': wrapper.id}) + + @self.route('/install/local/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + + file_bytes = file.read() + try: + with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf: + names = [name for name in zf.namelist() if not name.endswith('/')] + manifest_name = next( + ( + name + for name in names + if name.replace('\\', '/').strip('/').lower() in ('manifest.yaml', 'manifest.yml') + ), + None, + ) + if manifest_name is None: + return self.http_status(400, -1, 'manifest.yaml is required') + + manifest = yaml.safe_load(zf.read(manifest_name).decode('utf-8')) or {} + requirements: list[str] = [] + requirements_name = next( + (name for name in names if name.replace('\\', '/').strip('/').lower() == 'requirements.txt'), + None, + ) + if requirements_name is not None: + requirements = [ + line.strip() + for line in zf.read(requirements_name).decode('utf-8', errors='ignore').splitlines() + if line.strip() and not line.strip().startswith('#') + ] + + spec = manifest.get('spec') or {} + components = spec.get('components') or {} + component_counts = self._count_plugin_components(components, names) + component_types = list(component_counts.keys()) + + return self.success( + data={ + 'filename': file.filename or 'local plugin', + 'size': len(file_bytes), + 'manifest': manifest, + 'metadata': manifest.get('metadata') or {}, + 'component_types': component_types, + 'component_counts': component_counts, + 'requirements': requirements, + 'file_count': len(names), + } + ) + except zipfile.BadZipFile: + return self.http_status(400, -1, 'invalid .lbpkg file') + except Exception as exc: + return self.http_status(500, -1, f'Failed to preview plugin package: {exc}') + + @self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Upload a file for plugin configuration""" + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + + # Check file size (10MB limit) + MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + file_bytes = file.read() + if len(file_bytes) > MAX_FILE_SIZE: + return self.http_status(400, -1, 'file size exceeds 10MB limit') + + # Generate unique file key with original extension + original_filename = file.filename + _, ext = os.path.splitext(original_filename) + file_key = f'plugin_config_{uuid.uuid4().hex}{ext}' + + # Save file using storage manager + await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes) + + return self.success(data={'file_key': file_key}) + + @self.route('/config-files/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN) + async def _(file_key: str) -> str: + """Delete a plugin configuration file""" + # Only allow deletion of files with plugin_config_ prefix for security + if not file_key.startswith('plugin_config_'): + return self.http_status(400, -1, 'invalid file key') + + try: + await self.ap.storage_mgr.storage_provider.delete(file_key) + return self.success(data={'deleted': True}) + except Exception as e: + return self.http_status(500, -1, f'failed to delete file: {str(e)}') diff --git a/src/langbot/pkg/api/http/controller/groups/provider/__init__.py b/src/langbot/pkg/api/http/controller/groups/provider/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/http/controller/groups/provider/models.py b/src/langbot/pkg/api/http/controller/groups/provider/models.py new file mode 100644 index 0000000..f683c98 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/provider/models.py @@ -0,0 +1,147 @@ +import quart + +from ... import group + + +@group.group_class('models/llm', '/api/v1/provider/models/llm') +class LLMModelsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + if quart.request.method == 'GET': + provider_uuid = quart.request.args.get('provider_uuid') + if provider_uuid: + return self.success( + data={'models': await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)} + ) + return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + model_uuid = await self.ap.llm_model_service.create_llm_model(json_data) + return self.success(data={'uuid': model_uuid}) + + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(model_uuid: str) -> str: + if quart.request.method == 'GET': + model = await self.ap.llm_model_service.get_llm_model(model_uuid) + + if model is None: + return self.http_status(404, -1, 'model not found') + + return self.success(data={'model': model}) + elif quart.request.method == 'PUT': + json_data = await quart.request.json + + await self.ap.llm_model_service.update_llm_model(model_uuid, json_data) + + return self.success() + elif quart.request.method == 'DELETE': + await self.ap.llm_model_service.delete_llm_model(model_uuid) + + return self.success() + + @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(model_uuid: str) -> str: + json_data = await quart.request.json + + await self.ap.llm_model_service.test_llm_model(model_uuid, json_data) + + return self.success() + + +@group.group_class('models/embedding', '/api/v1/provider/models/embedding') +class EmbeddingModelsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + if quart.request.method == 'GET': + provider_uuid = quart.request.args.get('provider_uuid') + if provider_uuid: + return self.success( + data={ + 'models': await self.ap.embedding_models_service.get_embedding_models_by_provider( + provider_uuid + ) + } + ) + return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + model_uuid = await self.ap.embedding_models_service.create_embedding_model(json_data) + return self.success(data={'uuid': model_uuid}) + + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(model_uuid: str) -> str: + if quart.request.method == 'GET': + model = await self.ap.embedding_models_service.get_embedding_model(model_uuid) + + if model is None: + return self.http_status(404, -1, 'model not found') + + return self.success(data={'model': model}) + elif quart.request.method == 'PUT': + json_data = await quart.request.json + + await self.ap.embedding_models_service.update_embedding_model(model_uuid, json_data) + + return self.success() + elif quart.request.method == 'DELETE': + await self.ap.embedding_models_service.delete_embedding_model(model_uuid) + + return self.success() + + @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(model_uuid: str) -> str: + json_data = await quart.request.json + + await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data) + + return self.success() + + +@group.group_class('models/rerank', '/api/v1/provider/models/rerank') +class RerankModelsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + if quart.request.method == 'GET': + provider_uuid = quart.request.args.get('provider_uuid') + if provider_uuid: + return self.success( + data={ + 'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid) + } + ) + return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + model_uuid = await self.ap.rerank_models_service.create_rerank_model(json_data) + return self.success(data={'uuid': model_uuid}) + + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(model_uuid: str) -> str: + if quart.request.method == 'GET': + model = await self.ap.rerank_models_service.get_rerank_model(model_uuid) + + if model is None: + return self.http_status(404, -1, 'model not found') + + return self.success(data={'model': model}) + elif quart.request.method == 'PUT': + json_data = await quart.request.json + + await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data) + + return self.success() + elif quart.request.method == 'DELETE': + await self.ap.rerank_models_service.delete_rerank_model(model_uuid) + + return self.success() + + @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(model_uuid: str) -> str: + json_data = await quart.request.json + + await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data) + + return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/provider/providers.py b/src/langbot/pkg/api/http/controller/groups/provider/providers.py new file mode 100644 index 0000000..fcea598 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/provider/providers.py @@ -0,0 +1,56 @@ +import quart + +from ... import group + + +@group.group_class('models/providers', '/api/v1/provider/providers') +class ModelProvidersRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> str: + if quart.request.method == 'GET': + providers = await self.ap.provider_service.get_providers() + # Add model counts + for provider in providers: + counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid']) + provider['llm_count'] = counts['llm_count'] + provider['embedding_count'] = counts['embedding_count'] + provider['rerank_count'] = counts['rerank_count'] + return self.success(data={'providers': providers}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + provider_uuid = await self.ap.provider_service.create_provider(json_data) + return self.success(data={'uuid': provider_uuid}) + + @self.route( + '/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def _(provider_uuid: str) -> str: + if quart.request.method == 'GET': + provider = await self.ap.provider_service.get_provider(provider_uuid) + if provider is None: + return self.http_status(404, -1, 'provider not found') + counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid) + provider['llm_count'] = counts['llm_count'] + provider['embedding_count'] = counts['embedding_count'] + provider['rerank_count'] = counts['rerank_count'] + return self.success(data={'provider': provider}) + elif quart.request.method == 'PUT': + json_data = await quart.request.json + await self.ap.provider_service.update_provider(provider_uuid, json_data) + return self.success() + elif quart.request.method == 'DELETE': + try: + await self.ap.provider_service.delete_provider(provider_uuid) + return self.success() + except ValueError as e: + return self.http_status(400, -1, str(e)) + + @self.route('//scan-models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(provider_uuid: str) -> str: + try: + model_type = quart.request.args.get('type') + result = await self.ap.provider_service.scan_provider_models(provider_uuid, model_type) + return self.success(data=result) + except ValueError as e: + return self.http_status(400, -1, str(e)) diff --git a/src/langbot/pkg/api/http/controller/groups/provider/requesters.py b/src/langbot/pkg/api/http/controller/groups/provider/requesters.py new file mode 100644 index 0000000..268ed11 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/provider/requesters.py @@ -0,0 +1,39 @@ +import quart +import mimetypes + +from ... import group +from langbot.pkg.utils import importutil + + +@group.group_class('provider/requesters', '/api/v1/provider/requesters') +class RequestersRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET']) + async def _() -> quart.Response: + model_type = quart.request.args.get('type', '') + return self.success(data={'requesters': self.ap.model_mgr.get_available_requesters_info(model_type)}) + + @self.route('/', methods=['GET']) + async def _(requester_name: str) -> quart.Response: + requester_info = self.ap.model_mgr.get_available_requester_info_by_name(requester_name) + + if requester_info is None: + return self.http_status(404, -1, 'requester not found') + + return self.success(data={'requester': requester_info}) + + @self.route('//icon', methods=['GET'], auth_type=group.AuthType.NONE) + async def _(requester_name: str) -> quart.Response: + requester_manifest = self.ap.model_mgr.get_available_requester_manifest_by_name(requester_name) + + if requester_manifest is None: + return self.http_status(404, -1, 'requester not found') + + icon_path = requester_manifest.icon_rel_path + + if icon_path is None: + return self.http_status(404, -1, 'icon not found') + + return quart.Response( + importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] + ) diff --git a/src/langbot/pkg/api/http/controller/groups/resources/__init__.py b/src/langbot/pkg/api/http/controller/groups/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py new file mode 100644 index 0000000..27654e7 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import quart +import traceback +from urllib.parse import unquote + + +from ... import group + + +@group.group_class('mcp', '/api/v1/mcp') +class MCPRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """获取MCP服务器列表""" + if quart.request.method == 'GET': + servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) + + return self.success(data={'servers': servers}) + + elif quart.request.method == 'POST': + data = await quart.request.json + + try: + uuid = await self.ap.mcp_service.create_mcp_server(data) + return self.success(data={'uuid': uuid}) + except Exception as e: + traceback.print_exc() + return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}') + + @self.route( + '/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN + ) + async def _(server_name: str) -> str: + """获取、更新或删除MCP服务器配置""" + server_name = unquote(server_name) + + server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) + if server_data is None: + return self.http_status(404, -1, 'Server not found') + + if quart.request.method == 'GET': + return self.success(data={'server': server_data}) + + elif quart.request.method == 'PUT': + data = await quart.request.json + try: + await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data) + return self.success() + except Exception as e: + return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}') + + elif quart.request.method == 'DELETE': + try: + await self.ap.mcp_service.delete_mcp_server(server_data['uuid']) + return self.success() + except Exception as e: + return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}') + + @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """测试MCP服务器连接""" + server_name = unquote(server_name) + server_data = await quart.request.json + task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data) + return self.success(data={'task_id': task_id}) + + @self.route('/servers//resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """Get resources from an MCP server""" + server_name = unquote(server_name) + try: + resources = await self.ap.mcp_service.get_mcp_server_resources(server_name) + templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name) + runtime_info = await self.ap.mcp_service.get_runtime_info(server_name) + return self.success( + data={ + 'resources': resources, + 'resource_templates': templates, + 'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}), + } + ) + except Exception as e: + return self.http_status(500, -1, f'Failed to get resources: {str(e)}') + + @self.route( + '/servers//resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN + ) + async def _(server_name: str) -> str: + """Get resource templates from an MCP server""" + server_name = unquote(server_name) + try: + templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name) + return self.success(data={'resource_templates': templates}) + except Exception as e: + return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}') + + @self.route('/servers//logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """Get logs from an MCP server""" + server_name = unquote(server_name) + try: + limit = int(quart.request.args.get('limit', 200)) + except (TypeError, ValueError): + limit = 200 + limit = min(limit, 500) + level = quart.request.args.get('level') or None + logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level) + return self.success(data={'logs': logs}) + + @self.route('/servers//resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """Read a resource from an MCP server""" + server_name = unquote(server_name) + data = await quart.request.json + uri = data.get('uri') + if not uri: + return self.http_status(400, -1, 'URI is required') + try: + envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope( + server_name, + uri, + max_bytes=data.get('max_bytes'), + include_blob=bool(data.get('include_blob', False)), + ) + return self.success(data=envelope) + except Exception as e: + return self.http_status(500, -1, f'Failed to read resource: {str(e)}') diff --git a/src/langbot/pkg/api/http/controller/groups/resources/tools.py b/src/langbot/pkg/api/http/controller/groups/resources/tools.py new file mode 100644 index 0000000..128a064 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/resources/tools.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import quart + +from ... import group + + +@group.group_class('tools', '/api/v1/tools') +class ToolsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """获取所有可用工具列表""" + pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id') + bound_plugins: list[str] | None = None + bound_mcp_servers: list[str] | None = None + + if pipeline_uuid: + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) + if pipeline is None: + return self.http_status(404, -1, 'pipeline not found') + + extensions_prefs = pipeline.get('extensions_preferences', {}) or {} + if not extensions_prefs.get('enable_all_plugins', True): + bound_plugins = [ + f'{plugin.get("author", "")}/{plugin.get("name", "")}' + for plugin in extensions_prefs.get('plugins', []) + if isinstance(plugin, dict) and plugin.get('name') + ] + if not extensions_prefs.get('enable_all_mcp_servers', True): + bound_mcp_servers = [ + server for server in (extensions_prefs.get('mcp_servers', []) or []) if isinstance(server, str) + ] + + return self.success( + data={ + 'tools': await self.ap.tool_mgr.get_tool_catalog( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=True, + ) + } + ) + + @self.route('/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(tool_name: str) -> str: + """获取特定工具详情""" + tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True) + + for tool in tools: + if tool.name == tool_name: + return self.success( + data={ + 'tool': { + 'name': tool.name, + 'description': tool.description, + 'human_desc': tool.human_desc, + 'parameters': tool.parameters, + } + } + ) + + return self.http_status(404, -1, f'Tool not found: {tool_name}') diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py new file mode 100644 index 0000000..946741d --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import quart + +from langbot_plugin.box.errors import BoxError + +from .. import group + + +@group.group_class('skills', '/api/v1/skills') +class SkillsRouterGroup(group.RouterGroup): + """Skills management API endpoints.""" + + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def list_or_create_skills() -> quart.Response: + if quart.request.method == 'GET': + try: + skills = await self.ap.skill_service.list_skills() + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + return self.success(data={'skills': skills}) + + data = await quart.request.json + if 'name' not in data or not data['name']: + return self.http_status(400, -1, 'Missing required field: name') + + try: + skill = await self.ap.skill_service.create_skill(data) + return self.success(data={'skill': skill}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def get_update_delete_skill(skill_name: str) -> quart.Response: + if quart.request.method == 'GET': + try: + skill = await self.ap.skill_service.get_skill(skill_name) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + if not skill: + return self.http_status(404, -1, 'Skill not found') + return self.success(data={'skill': skill}) + + if quart.request.method == 'PUT': + data = await quart.request.json + try: + skill = await self.ap.skill_service.update_skill(skill_name, data) + return self.success(data={'skill': skill}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + try: + await self.ap.skill_service.delete_skill(skill_name) + return self.success() + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + @self.route('//files', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def list_skill_files(skill_name: str) -> quart.Response: + """List files in skill package directory.""" + path = quart.request.args.get('path', '.').strip() + include_hidden = quart.request.args.get('include_hidden', 'false').lower() == 'true' + + try: + result = await self.ap.skill_service.list_skill_files( + skill_name, + path=path, + include_hidden=include_hidden, + ) + return self.success(data=result) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + @self.route( + '//files/', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) + async def read_or_write_skill_file(skill_name: str, path: str) -> quart.Response: + """Read or write a file in skill package.""" + if quart.request.method == 'GET': + try: + result = await self.ap.skill_service.read_skill_file(skill_name, path) + return self.success(data=result) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + # PUT - write file + data = await quart.request.json + content = data.get('content', '') + if content is None: + return self.http_status(400, -1, 'Missing required field: content') + + try: + result = await self.ap.skill_service.write_skill_file(skill_name, path, content) + return self.success(data=result) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + + @self.route('//preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def preview_skill(skill_name: str) -> quart.Response: + skill = self.ap.skill_mgr.get_skill_by_name(skill_name) + if not skill: + return self.http_status(404, -1, 'Skill not found') + return self.success(data={'instructions': skill.get('instructions', '')}) + + @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def install_skill_from_github() -> quart.Response: + data = await quart.request.json + required_fields = ['asset_url', 'owner', 'repo'] + for field in required_fields: + if field not in data or not data[field]: + return self.http_status(400, -1, f'Missing required field: {field}') + asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0] + if not asset_url.endswith('skill.md') and not data.get('release_tag'): + return self.http_status(400, -1, 'Missing required field: release_tag') + + try: + skill = await self.ap.skill_service.install_from_github(data) + return self.success(data={'skills': skill}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to install skill: {exc}') + + @self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def preview_skill_from_github() -> quart.Response: + data = await quart.request.json + required_fields = ['asset_url', 'owner', 'repo'] + for field in required_fields: + if field not in data or not data[field]: + return self.http_status(400, -1, f'Missing required field: {field}') + asset_url = str(data['asset_url']).strip().lower().split('?', 1)[0].split('#', 1)[0] + if not asset_url.endswith('skill.md') and not data.get('release_tag'): + return self.http_status(400, -1, 'Missing required field: release_tag') + + try: + preview = await self.ap.skill_service.preview_install_from_github(data) + return self.success(data={'skills': preview}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to preview skill: {exc}') + + @self.route('/install/upload', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def install_skill_from_upload() -> quart.Response: + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + form = await quart.request.form + + try: + skill = await self.ap.skill_service.install_from_zip_upload( + file_bytes=file.read(), + filename=file.filename or '', + source_paths=form.getlist('source_paths'), + ) + return self.success(data={'skills': skill}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to install skill: {exc}') + + @self.route('/install/upload/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def preview_skill_from_upload() -> quart.Response: + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + + try: + preview = await self.ap.skill_service.preview_install_from_zip_upload( + file_bytes=file.read(), + filename=file.filename or '', + ) + return self.success(data={'skills': preview}) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to preview skill: {exc}') + + @self.route('/scan', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def scan_skill_directory() -> quart.Response: + path = quart.request.args.get('path', '').strip() + if not path: + return self.http_status(400, -1, 'Missing required parameter: path') + + try: + result = await self.ap.skill_service.scan_directory_async(path) + return self.success(data=result) + except (ValueError, BoxError) as exc: + return self.http_status(400, -1, str(exc)) diff --git a/src/langbot/pkg/api/http/controller/groups/stats.py b/src/langbot/pkg/api/http/controller/groups/stats.py new file mode 100644 index 0000000..8c8e911 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/stats.py @@ -0,0 +1,19 @@ +from .. import group + + +@group.group_class('stats', '/api/v1/stats') +class StatsRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/basic', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + conv_count = 0 + for session in self.ap.sess_mgr.session_list: + conv_count += len(session.conversations if session.conversations is not None else []) + + return self.success( + data={ + 'active_session_count': len(self.ap.sess_mgr.session_list), + 'conversation_count': conv_count, + 'query_count': self.ap.query_pool.query_id_counter, + } + ) diff --git a/src/langbot/pkg/api/http/controller/groups/survey.py b/src/langbot/pkg/api/http/controller/groups/survey.py new file mode 100644 index 0000000..a65d51a --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/survey.py @@ -0,0 +1,93 @@ +import base64 + +import quart + +from .. import group + + +@group.group_class('survey', '/api/v1/survey') +class SurveyRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/pending', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _get_pending() -> str: + """Get pending survey for the frontend to display.""" + survey = self.ap.survey.get_pending_survey() if self.ap.survey else None + return self.success(data={'survey': survey}) + + @self.route('/respond', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _respond() -> str: + """Submit survey response.""" + json_data = await quart.request.json + survey_id = json_data.get('survey_id') + answers = json_data.get('answers', {}) + completed = json_data.get('completed', True) + + if not survey_id: + return self.fail(1, 'survey_id required') + + if self.ap.survey: + ok = await self.ap.survey.submit_response(survey_id, answers, completed) + if ok: + return self.success() + return self.fail(2, 'Failed to submit response') + return self.fail(3, 'Survey not available') + + @self.route('/feedback', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _feedback(user_email: str) -> str: + """Submit on-demand user feedback from the sidebar.""" + json_data = await quart.request.get_json(silent=True) or {} + content = str(json_data.get('content', '')).strip() + attachments = json_data.get('attachments', []) + + if not content: + return self.fail(1, 'content required') + if len(content) > 5000: + return self.fail(2, 'content too long') + if not isinstance(attachments, list): + return self.fail(3, 'attachments must be an array') + if len(attachments) > 3: + return self.fail(4, 'too many attachments') + + normalized_attachments = [] + for item in attachments: + if not isinstance(item, dict): + continue + data_url = str(item.get('data_url', '')) + mime_type = str(item.get('mime_type', ''))[:128] + name = str(item.get('name', ''))[:255] + if not data_url.startswith('data:image/'): + continue + try: + payload = data_url.split(',', 1)[1] + if len(base64.b64decode(payload, validate=True)) > 1024 * 1024: + return self.fail(5, 'attachment too large') + except Exception: + return self.fail(5, 'attachment too large') + normalized_attachments.append({'name': name, 'mime_type': mime_type, 'data_url': data_url}) + + if self.ap.survey: + ok = await self.ap.survey.submit_feedback( + content=content, + attachments=normalized_attachments, + user_email=user_email, + ) + if ok: + return self.success() + return self.fail(6, 'Failed to submit feedback') + return self.fail(7, 'Survey not available') + + @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _dismiss() -> str: + """Dismiss survey.""" + json_data = await quart.request.json + survey_id = json_data.get('survey_id') + + if not survey_id: + return self.fail(1, 'survey_id required') + + if self.ap.survey: + ok = await self.ap.survey.dismiss_survey(survey_id) + if ok: + return self.success() + return self.fail(2, 'Failed to dismiss') + return self.fail(3, 'Survey not available') diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py new file mode 100644 index 0000000..236a235 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -0,0 +1,202 @@ +import json + +import quart +import sqlalchemy + +from .. import group +from .....utils import constants +from .....entity.persistence.metadata import Metadata + + +@group.group_class('system', '/api/v1/system') +class SystemRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE) + async def _() -> str: + # Read wizard_status and wizard_progress from metadata table + wizard_status = 'none' + wizard_progress = None + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress'])) + ) + for row in result: + if row.key == 'wizard_status': + wizard_status = row.value + elif row.key == 'wizard_progress': + try: + wizard_progress = json.loads(row.value) + except (json.JSONDecodeError, TypeError): + wizard_progress = None + except Exception: + pass + + # ``system.outbound_ips`` may be a comma-separated string instead of + # a list when injected via the SYSTEM__OUTBOUND_IPS env var into a + # pre-existing data/config.yaml that lacks the key (env overrides + # only coerce to list when the key already holds one). + outbound_ips = self.ap.instance_config.data.get('system', {}).get('outbound_ips', []) + if isinstance(outbound_ips, str): + outbound_ips = [ip.strip() for ip in outbound_ips.split(',') if ip.strip()] + elif isinstance(outbound_ips, list): + outbound_ips = [str(ip).strip() for ip in outbound_ips if str(ip).strip()] + else: + outbound_ips = [] + + return self.success( + data={ + 'version': constants.semantic_version, + 'debug': constants.debug_mode, + 'edition': constants.edition, + 'enable_marketplace': self.ap.instance_config.data.get('plugin', {}).get( + 'enable_marketplace', True + ), + 'cloud_service_url': ( + self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app') + ), + 'allow_modify_login_info': self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ), + 'disable_models_service': self.ap.instance_config.data.get('space', {}).get( + 'disable_models_service', False + ), + 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), + 'outbound_ips': outbound_ips, + 'wizard_status': wizard_status, + 'wizard_progress': wizard_progress, + } + ) + + @self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Mark wizard status in metadata table and clear progress. + + Accepts JSON body: { "status": "skipped" | "completed" } + """ + data = await quart.request.get_json(silent=True) or {} + status = data.get('status', 'completed') + if status not in ('skipped', 'completed'): + return self.http_status(400, 400, f'Invalid wizard status: {status}') + + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status') + ) + if result.first(): + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status) + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(Metadata).values(key='wizard_status', value=status) + ) + + # Clear wizard progress when wizard is completed/skipped + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress') + ) + except Exception as e: + return self.http_status(500, 500, f'Failed to update wizard status: {e}') + + return self.success(data={}) + + @self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """Save wizard progress to metadata table. + + Accepts JSON body with wizard state fields: + { "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null, + "bot_saved": bool, "selected_runner": str|null } + """ + data = await quart.request.get_json(silent=True) or {} + progress_json = json.dumps(data, ensure_ascii=False) + + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress') + ) + if result.first(): + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json) + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json) + ) + except Exception as e: + return self.http_status(500, 500, f'Failed to save wizard progress: {e}') + + return self.success(data={}) + + @self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + task_type = quart.request.args.get('type') + task_kind = quart.request.args.get('kind') + + if task_type == '': + task_type = None + if task_kind == '': + task_kind = None + + return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind)) + + @self.route('/tasks/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(task_id: str) -> str: + task = self.ap.task_mgr.get_task_by_id(int(task_id)) + + if task is None: + return self.http_status(404, 404, 'Task not found') + + return self.success(data=task.to_dict()) + + @self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + return self.success(data=await self.ap.maintenance_service.get_storage_analysis()) + + @self.route( + '/debug/plugin/action', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _() -> str: + if not constants.debug_mode: + return self.http_status(403, 403, 'Forbidden') + + data = await quart.request.json + + class AnoymousAction: + value = 'anonymous_action' + + def __init__(self, value: str): + self.value = value + + resp = await self.ap.plugin_connector.handler.call_action( + AnoymousAction(data['action']), + data['data'], + timeout=data.get('timeout', 10), + ) + + return self.success(data=resp) + + @self.route( + '/status/plugin-system', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN, + ) + async def _() -> str: + plugin_connector_error = 'ok' + is_connected = True + + try: + await self.ap.plugin_connector.ping_plugin_runtime() + except Exception as e: + plugin_connector_error = str(e) + is_connected = False + + return self.success( + data={ + 'is_enable': self.ap.plugin_connector.is_enable_plugin, + 'is_connected': is_connected, + 'plugin_connector_error': plugin_connector_error, + } + ) diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py new file mode 100644 index 0000000..886dc5d --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -0,0 +1,270 @@ +import quart +import argon2 +import asyncio +import traceback + +from .. import group +from .....entity.errors import account as account_errors + + +@group.group_class('user', '/api/v1/user') +class UserRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + if quart.request.method == 'GET': + return self.success(data={'initialized': await self.ap.user_service.is_initialized()}) + + if await self.ap.user_service.is_initialized(): + return self.fail(1, 'System already initialized') + + json_data = await quart.request.json + + user_email = json_data['user'] + password = json_data['password'] + + await self.ap.user_service.create_user(user_email, password) + + return self.success() + + @self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + json_data = await quart.request.json + + try: + token = await self.ap.user_service.authenticate(json_data['user'], json_data['password']) + except argon2.exceptions.VerifyMismatchError: + return self.fail(1, 'Invalid username or password') + except ValueError as e: + return self.fail(1, str(e)) + + return self.success(data={'token': token}) + + @self.route('/check-token', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + token = await self.ap.user_service.generate_jwt_token(user_email) + + return self.success(data={'token': token}) + + @self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + json_data = await quart.request.json + + user_email = json_data['user'] + recovery_key = json_data['recovery_key'] + new_password = json_data['new_password'] + + # hard sleep 3s for security + await asyncio.sleep(3) + + if not await self.ap.user_service.is_initialized(): + return self.http_status(400, -1, 'System not initialized') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + + if user_obj is None: + return self.http_status(400, -1, 'User not found') + + if recovery_key != self.ap.instance_config.data['system']['recovery_key']: + return self.http_status(403, -1, 'Invalid recovery key') + + await self.ap.user_service.reset_password(user_email, new_password) + + return self.success(data={'user': user_email}) + + @self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + # Check if password change is allowed + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + json_data = await quart.request.json + + current_password = json_data['current_password'] + new_password = json_data['new_password'] + + try: + await self.ap.user_service.change_password(user_email, current_password, new_password) + except argon2.exceptions.VerifyMismatchError: + return self.http_status(400, -1, 'Current password is incorrect') + except ValueError as e: + return self.http_status(400, -1, str(e)) + + return self.success(data={'user': user_email}) + + # Space OAuth endpoints (redirect flow) + + @self.route('/space/authorize-url', methods=['GET'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Get Space OAuth authorization URL for redirect""" + redirect_uri = quart.request.args.get('redirect_uri', '') + state = quart.request.args.get('state', '') + + if not redirect_uri: + return self.fail(1, 'Missing redirect_uri parameter') + + try: + authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state) + return self.success(data={'authorize_url': authorize_url}) + except Exception as e: + return self.fail(1, str(e)) + + @self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Handle OAuth callback - exchange code for tokens and authenticate""" + json_data = await quart.request.json + code = json_data.get('code') + + if not code: + return self.fail(1, 'Missing authorization code') + + try: + # Exchange code for tokens + token_data = await self.ap.space_service.exchange_oauth_code(code) + access_token = token_data.get('access_token') + refresh_token = token_data.get('refresh_token') + expires_in = token_data.get('expires_in', 0) + + if not access_token: + return self.fail(1, 'Failed to get access token from Space') + + # Authenticate and create/update local user + jwt_token, user_obj = await self.ap.user_service.authenticate_space_user( + access_token, refresh_token, expires_in + ) + + return self.success( + data={ + 'token': jwt_token, + 'user': user_obj.user, + } + ) + except account_errors.AccountEmailMismatchError as e: + return self.fail(3, str(e)) + except ValueError as e: + traceback.print_exc() + self.ap.logger.warning(f'Space OAuth callback failed: {e}') + return self.fail(1, str(e)) + except Exception as e: + traceback.print_exc() + return self.fail(2, f'OAuth callback failed: {str(e)}') + + @self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Get current user information including account type""" + user_obj = await self.ap.user_service.get_user_by_email(user_email) + + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + return self.success( + data={ + 'user': user_obj.user, + 'account_type': user_obj.account_type, + 'has_password': bool(user_obj.password and user_obj.password.strip()), + } + ) + + @self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Get Space credits balance for current user""" + credits = await self.ap.space_service.get_credits(user_email) + return self.success(data={'credits': credits}) + + @self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Get account info for login page (account type and has_password)""" + if not await self.ap.user_service.is_initialized(): + return self.success(data={'initialized': False}) + + user_obj = await self.ap.user_service.get_first_user() + if user_obj is None: + return self.success(data={'initialized': False}) + + return self.success( + data={ + 'initialized': True, + 'account_type': user_obj.account_type, + 'has_password': bool(user_obj.password and user_obj.password.strip()), + } + ) + + @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Set password for Space account (first time) or change password""" + # Check if modifying login info is allowed + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + json_data = await quart.request.json + new_password = json_data.get('new_password') + current_password = json_data.get('current_password') + + if not new_password: + return self.http_status(400, -1, 'New password is required') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + try: + await self.ap.user_service.set_password(user_email, new_password, current_password) + return self.success(data={'user': user_email}) + except ValueError as e: + return self.http_status(400, -1, str(e)) + except argon2.exceptions.VerifyMismatchError: + return self.http_status(400, -1, 'Current password is incorrect') + + @self.route('/bind-space', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Bind Space account to existing local account""" + # Check if modifying login info is allowed + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + json_data = await quart.request.json + code = json_data.get('code') + state = json_data.get('state') # JWT token passed as state + + if not code: + return self.http_status(400, -1, 'Missing authorization code') + + if not state: + return self.http_status(400, -1, 'Missing state parameter') + + # Verify state is a valid JWT token + try: + user_email = await self.ap.user_service.verify_jwt_token(state) + except Exception: + return self.http_status(401, -1, 'Invalid or expired state') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + if user_obj.account_type != 'local': + return self.http_status(400, -1, 'Only local accounts can bind to Space') + + try: + updated_user = await self.ap.user_service.bind_space_account(user_email, code) + jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user) + return self.success( + data={ + 'token': jwt_token, + 'user': updated_user.user, + 'account_type': updated_user.account_type, + } + ) + except ValueError as e: + return self.http_status(400, -1, str(e)) + except Exception as e: + return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}') diff --git a/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py b/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py new file mode 100644 index 0000000..f82184c --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py @@ -0,0 +1,49 @@ +import quart + +from .. import group + + +@group.group_class('webhook_mgmt', '/api/v1/webhooks') +class WebhookManagementRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST']) + async def _() -> str: + if quart.request.method == 'GET': + webhooks = await self.ap.webhook_service.get_webhooks() + return self.success(data={'webhooks': webhooks}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + name = json_data.get('name', '') + url = json_data.get('url', '') + description = json_data.get('description', '') + enabled = json_data.get('enabled', True) + + if not name: + return self.http_status(400, -1, 'Name is required') + if not url: + return self.http_status(400, -1, 'URL is required') + + webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled) + return self.success(data={'webhook': webhook}) + + @self.route('/', methods=['GET', 'PUT', 'DELETE']) + async def _(webhook_id: int) -> str: + if quart.request.method == 'GET': + webhook = await self.ap.webhook_service.get_webhook(webhook_id) + if webhook is None: + return self.http_status(404, -1, 'Webhook not found') + return self.success(data={'webhook': webhook}) + + elif quart.request.method == 'PUT': + json_data = await quart.request.json + name = json_data.get('name') + url = json_data.get('url') + description = json_data.get('description') + enabled = json_data.get('enabled') + + await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled) + return self.success() + + elif quart.request.method == 'DELETE': + await self.ap.webhook_service.delete_webhook(webhook_id) + return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/webhooks.py b/src/langbot/pkg/api/http/controller/groups/webhooks.py new file mode 100644 index 0000000..ec46c74 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/webhooks.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import quart +import traceback + +from .. import group + + +@group.group_class('webhooks', '/bots') +class WebhookRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) + async def handle_webhook(bot_uuid: str): + """处理 bot webhook 回调(无子路径)""" + return await self._dispatch_webhook(bot_uuid, '') + + @self.route('//', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) + async def handle_webhook_with_path(bot_uuid: str, path: str): + """处理 bot webhook 回调(带子路径)""" + return await self._dispatch_webhook(bot_uuid, path) + + async def _dispatch_webhook(self, bot_uuid: str, path: str): + """分发 webhook 请求到对应的 bot adapter + + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) + + Returns: + 适配器返回的响应 + """ + try: + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + + if not runtime_bot: + return quart.jsonify({'error': 'Bot not found'}), 404 + + if not runtime_bot.enable: + return quart.jsonify({'error': 'Bot is disabled'}), 403 + + if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'): + return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501 + + response = await runtime_bot.adapter.handle_unified_webhook( + bot_uuid=bot_uuid, + path=path, + request=quart.request, + ) + + return response + + except Exception as e: + self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}') + return quart.jsonify({'error': str(e)}), 500 diff --git a/src/langbot/pkg/api/http/controller/main.py b/src/langbot/pkg/api/http/controller/main.py new file mode 100644 index 0000000..835617e --- /dev/null +++ b/src/langbot/pkg/api/http/controller/main.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import asyncio +import os + +import quart +import quart_cors +from werkzeug.exceptions import RequestEntityTooLarge + +from ....core import app, entities as core_entities +from ....utils import importutil + +from . import groups +from . import group +from .groups import provider as groups_provider +from .groups import platform as groups_platform +from .groups import pipelines as groups_pipelines +from .groups import knowledge as groups_knowledge +from .groups import resources as groups_resources +from ...mcp.mount import MCPMount + +importutil.import_modules_in_pkg(groups) +importutil.import_modules_in_pkg(groups_provider) +importutil.import_modules_in_pkg(groups_platform) +importutil.import_modules_in_pkg(groups_pipelines) +importutil.import_modules_in_pkg(groups_knowledge) +importutil.import_modules_in_pkg(groups_resources) + + +class HTTPController: + ap: app.Application + + quart_app: quart.Quart + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + self.quart_app = quart.Quart(__name__) + quart_cors.cors(self.quart_app, allow_origin='*') + + # Set maximum content length to prevent large file uploads + self.quart_app.config['MAX_CONTENT_LENGTH'] = group.MAX_FILE_SIZE + + # MCP server (mounted at /mcp, see ..mcp.mount). Built lazily in + # initialize() so the service layer is ready. + self.mcp_mount: MCPMount | None = None + + async def initialize(self) -> None: + # Register custom error handler for file size limit + @self.quart_app.errorhandler(RequestEntityTooLarge) + async def handle_request_entity_too_large(e): + return quart.jsonify( + { + 'code': 400, + 'msg': 'File size exceeds 10MB limit. Please split large files into smaller parts.', + } + ), 400 + + await self.register_routes() + + # Build the MCP server and start its session-manager lifespan in the + # background so the streamable-HTTP transport is ready to serve. + self.mcp_mount = MCPMount(self.ap) + await self.mcp_mount.start_session_manager() + self.ap.logger.info('LangBot MCP server mounted at /mcp (API-key authenticated).') + + async def run(self) -> None: + if True: + + async def shutdown_trigger_placeholder(): + while True: + await asyncio.sleep(1) + + async def exception_handler(*args, **kwargs): + try: + await self._run_task(*args, **kwargs) + except Exception as e: + self.ap.logger.error(f'Failed to start HTTP service: {e}') + + self.ap.task_mgr.create_task( + exception_handler( + host='0.0.0.0', + port=self.ap.instance_config.data['api']['port'], + shutdown_trigger=shutdown_trigger_placeholder, + ), + name='http-api-quart', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + + # await asyncio.sleep(5) + + async def _run_task(self, host: str, port: int, shutdown_trigger) -> None: + """Serve the Quart app, fronted by the MCP dispatcher at /mcp. + + Mirrors Quart.run_task() but wraps the ASGI app so MCP requests are + intercepted before Quart's router. Falls back to plain Quart if the + MCP mount failed to build for any reason. + """ + from hypercorn.config import Config as HyperConfig + from hypercorn.asyncio import serve as hypercorn_serve + + config = HyperConfig() + config.access_log_format = '%(h)s %(r)s %(s)s %(b)s %(D)s' + config.accesslog = '-' + config.bind = [f'{host}:{port}'] + config.errorlog = config.accesslog + + asgi_app = self.quart_app + if self.mcp_mount is not None: + asgi_app = self.mcp_mount.wrap(self.quart_app) + + await hypercorn_serve(asgi_app, config, shutdown_trigger=shutdown_trigger) + + async def register_routes(self) -> None: + @self.quart_app.route('/healthz') + async def healthz(): + return {'code': 0, 'msg': 'ok'} + + for g in group.preregistered_groups: + ginst = g(self.ap, self.quart_app) + await ginst.initialize() + + from ....utils import paths + + frontend_path = paths.get_frontend_path() + + @self.quart_app.route('/') + async def index(): + response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html') + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + return response + + @self.quart_app.route('/') + async def static_file(path: str): + if not ( + os.path.exists(os.path.join(frontend_path, path)) and os.path.isfile(os.path.join(frontend_path, path)) + ): + if os.path.exists(os.path.join(frontend_path, path + '.html')): + path += '.html' + elif not path.startswith('api/'): + # SPA fallback: serve index.html for all non-API, non-static routes + # so that React Router can handle client-side routing (Vite SPA). + # For /home/* sub-routes, first try parent .html files (pre-rendered pages). + if path.startswith('home/'): + segments = path.rstrip('/').split('/') + for i in range(len(segments) - 1, 0, -1): + parent_path = '/'.join(segments[:i]) + '.html' + if os.path.exists(os.path.join(frontend_path, parent_path)): + response = await quart.send_from_directory( + frontend_path, parent_path, mimetype='text/html' + ) + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + return response + + # Fallback to index.html for SPA client-side routing + response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html') + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + return response + else: + return await quart.send_from_directory(frontend_path, '404.html') + + mimetype = None + + if path.endswith('.html'): + mimetype = 'text/html' + elif path.endswith('.js'): + mimetype = 'application/javascript' + elif path.endswith('.css'): + mimetype = 'text/css' + elif path.endswith('.png'): + mimetype = 'image/png' + elif path.endswith('.jpg'): + mimetype = 'image/jpeg' + elif path.endswith('.jpeg'): + mimetype = 'image/jpeg' + elif path.endswith('.gif'): + mimetype = 'image/gif' + elif path.endswith('.svg'): + mimetype = 'image/svg+xml' + elif path.endswith('.ico'): + mimetype = 'image/x-icon' + elif path.endswith('.json'): + mimetype = 'application/json' + elif path.endswith('.txt'): + mimetype = 'text/plain' + + response = await quart.send_from_directory(frontend_path, path, mimetype=mimetype) + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + return response diff --git a/src/langbot/pkg/api/http/service/__init__.py b/src/langbot/pkg/api/http/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/api/http/service/apikey.py b/src/langbot/pkg/api/http/service/apikey.py new file mode 100644 index 0000000..2072543 --- /dev/null +++ b/src/langbot/pkg/api/http/service/apikey.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import secrets +import sqlalchemy + +from ....core import app +from ....entity.persistence import apikey + + +class ApiKeyService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_api_keys(self) -> list[dict]: + """Get all API keys""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey)) + + keys = result.all() + return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys] + + async def create_api_key(self, name: str, description: str = '') -> dict: + """Create a new API key""" + # Generate a secure random API key + key = f'lbk_{secrets.token_urlsafe(32)}' + + key_data = {'name': name, 'key': key, 'description': description} + + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data)) + + # Retrieve the created key + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key) + ) + created_key = result.first() + + return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key) + + async def get_api_key(self, key_id: int) -> dict | None: + """Get a specific API key by ID""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id) + ) + + key = result.first() + + if key is None: + return None + + return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) + + async def verify_api_key(self, key: str) -> bool: + """Verify if an API key is valid. + + A key is accepted if it matches the global API key configured in + ``config.yaml`` (``api.global_api_key``) — which requires no login + session and no database record — or if it matches a key created via + the web UI (stored in the database, prefixed with ``lbk_``). + """ + if not isinstance(key, str) or not key: + return False + + # 1. Global API key from config.yaml (no DB lookup, no login state). + # Note: config completion only backfills top-level keys, so existing + # installs may not have this key — access it defensively. + global_api_key = self.ap.instance_config.data.get('api', {}).get('global_api_key', '') + if global_api_key and secrets.compare_digest(key, global_api_key): + return True + + # 2. Web-UI-created keys are stored in the database and prefixed lbk_. + if not key.startswith('lbk_'): + return False + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key) + ) + + key_obj = result.first() + return key_obj is not None + + async def delete_api_key(self, key_id: int) -> None: + """Delete an API key""" + await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id)) + + async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None: + """Update an API key's metadata (name, description)""" + update_data = {} + if name is not None: + update_data['name'] = name + if description is not None: + update_data['description'] = description + + if update_data: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data) + ) diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py new file mode 100644 index 0000000..995267c --- /dev/null +++ b/src/langbot/pkg/api/http/service/bot.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import uuid +import sqlalchemy +import typing + +from ....core import app +from ....entity.persistence import bot as persistence_bot +from ....entity.persistence import pipeline as persistence_pipeline + + +class BotService: + """Bot service""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_bots(self, include_secret: bool = True) -> list[dict]: + """获取所有机器人""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot)) + + bots = result.all() + + masked_columns = [] + if not include_secret: + masked_columns = ['adapter_config'] + + return [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) for bot in bots] + + async def get_bot(self, bot_uuid: str, include_secret: bool = True) -> dict | None: + """获取机器人""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid) + ) + + bot = result.first() + + if bot is None: + return None + + masked_columns = [] + if not include_secret: + masked_columns = ['adapter_config'] + + return self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) + + async def get_runtime_bot_info(self, bot_uuid: str, include_secret: bool = True) -> dict: + """获取机器人运行时信息""" + persistence_bot = await self.get_bot(bot_uuid, include_secret) + if persistence_bot is None: + raise Exception('Bot not found') + + adapter_runtime_values = {} + + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + if runtime_bot is not None: + adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id + + # Webhook URL for unified webhook adapters (independent of bot running state) + if persistence_bot['adapter'] in [ + 'wecom', + 'wecombot', + 'officialaccount', + 'qqofficial', + 'slack', + 'wecomcs', + 'LINE', + 'lark', + ]: + webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300') + extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '') + webhook_url = f'/bots/{bot_uuid}' + adapter_runtime_values['webhook_url'] = webhook_url + adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}' + adapter_runtime_values['extra_webhook_full_url'] = ( + f'{extra_webhook_prefix}{webhook_url}' if extra_webhook_prefix else '' + ) + else: + adapter_runtime_values['webhook_url'] = None + adapter_runtime_values['webhook_full_url'] = None + adapter_runtime_values['extra_webhook_full_url'] = None + + persistence_bot['adapter_runtime_values'] = adapter_runtime_values + + return persistence_bot + + async def create_bot(self, bot_data: dict) -> str: + """Create bot""" + # Check limitation + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + max_bots = limitation.get('max_bots', -1) + if max_bots >= 0: + existing_bots = await self.get_bots() + if len(existing_bots) >= max_bots: + raise ValueError(f'Maximum number of bots ({max_bots}) reached') + + # TODO: 检查配置信息格式 + bot_data['uuid'] = str(uuid.uuid4()) + + # bind the most recently updated pipeline if any exist + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline) + .order_by(persistence_pipeline.LegacyPipeline.updated_at.desc()) + .limit(1) + ) + pipeline = result.first() + if pipeline is not None: + bot_data['use_pipeline_uuid'] = pipeline.uuid + bot_data['use_pipeline_name'] = pipeline.name + + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data)) + + bot = await self.get_bot(bot_data['uuid']) + + await self.ap.platform_mgr.load_bot(bot) + + return bot_data['uuid'] + + async def update_bot(self, bot_uuid: str, bot_data: dict) -> None: + """Update bot""" + update_data = bot_data.copy() + + if 'uuid' in update_data: + del update_data['uuid'] + + # set use_pipeline_name + if 'use_pipeline_uuid' in update_data: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid'] + ) + ) + pipeline = result.first() + if pipeline is not None: + update_data['use_pipeline_name'] = pipeline.name + else: + raise Exception('Pipeline not found') + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid) + ) + await self.ap.platform_mgr.remove_bot(bot_uuid) + + # select from db + bot = await self.get_bot(bot_uuid) + + runtime_bot = await self.ap.platform_mgr.load_bot(bot) + + if runtime_bot.enable: + await runtime_bot.run() + + # update all conversation that use this bot + for session in self.ap.sess_mgr.session_list: + if session.using_conversation is not None and session.using_conversation.bot_uuid == bot_uuid: + session.using_conversation = None + + async def delete_bot(self, bot_uuid: str) -> None: + """Delete bot""" + await self.ap.platform_mgr.remove_bot(bot_uuid) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid) + ) + + async def list_event_logs( + self, bot_uuid: str, from_index: int, max_count: int + ) -> typing.Tuple[list[dict], int, int, int]: + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + if runtime_bot is None: + raise Exception('Bot not found') + + logs, total_count = await runtime_bot.logger.get_logs(from_index, max_count) + + return [log.to_json() for log in logs], total_count + + async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None: + """Send message to a specific target via bot + + Args: + bot_uuid: The UUID of the bot + target_type: The type of the target, can be "group", "person" + target_id: The ID of the target + message_chain_data: The message chain data in dict format + """ + # Import here to avoid circular imports + import langbot_plugin.api.entities.builtin.platform.message as platform_message + + # Get runtime bot + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + if runtime_bot is None: + raise Exception(f'Bot not found: {bot_uuid}') + + # Validate and convert message chain + try: + message_chain = platform_message.MessageChain.model_validate(message_chain_data) + except Exception as e: + raise Exception(f'Invalid message_chain format: {str(e)}') + + # Send message via adapter + await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain) + + # ============ Bot Admins ============ + + async def get_bot_admins(self, bot_uuid: str) -> list[dict]: + from ....entity.persistence import bot as persistence_bot + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid) + ) + return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()] + + async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int: + from ....entity.persistence import bot as persistence_bot + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_bot.BotAdmin).values( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + ) + ) + return result.inserted_primary_key[0] + + async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None: + from ....entity.persistence import bot as persistence_bot + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_bot.BotAdmin).where( + persistence_bot.BotAdmin.bot_uuid == bot_uuid, + persistence_bot.BotAdmin.id == admin_id, + ) + ) diff --git a/src/langbot/pkg/api/http/service/knowledge.py b/src/langbot/pkg/api/http/service/knowledge.py new file mode 100644 index 0000000..48cb7ca --- /dev/null +++ b/src/langbot/pkg/api/http/service/knowledge.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import sqlalchemy + +from ....core import app +from ....entity.persistence import rag as persistence_rag + + +class KnowledgeService: + """知识库服务""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_knowledge_bases(self) -> list[dict]: + """获取所有知识库""" + return await self.ap.rag_mgr.get_all_knowledge_base_details() + + async def get_knowledge_base(self, kb_uuid: str) -> dict | None: + """获取知识库""" + return await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid) + + async def create_knowledge_base(self, kb_data: dict) -> str: + """创建知识库""" + # In new architecture, we delegate entirely to RAGManager which uses plugins. + # Legacy internal KB creation is removed. + + knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id') + if not knowledge_engine_plugin_id: + raise ValueError('knowledge_engine_plugin_id is required') + + creation_settings = kb_data.get('creation_settings', {}) + retrieval_settings = kb_data.get('retrieval_settings', {}) + + # Validate required fields based on plugin's creation_schema and retrieval_schema + await self._validate_schema_required_fields( + knowledge_engine_plugin_id, + creation_settings, + retrieval_settings, + ) + + kb = await self.ap.rag_mgr.create_knowledge_base( + name=kb_data.get('name', 'Untitled'), + knowledge_engine_plugin_id=knowledge_engine_plugin_id, + creation_settings=creation_settings, + retrieval_settings=retrieval_settings, + description=kb_data.get('description', ''), + ) + return kb.uuid + + async def _validate_schema_required_fields( + self, + plugin_id: str, + creation_settings: dict, + retrieval_settings: dict, + ) -> None: + """Validate required fields based on plugin's creation_schema and retrieval_schema. + + This is a business-agnostic validation that checks all fields marked as + required in the plugin's schema, regardless of field type. + + Args: + plugin_id: Knowledge Engine plugin ID. + creation_settings: User-provided creation settings. + retrieval_settings: User-provided retrieval settings. + + Raises: + ValueError: If any required field is missing or empty. + """ + # Validate creation_schema + try: + creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id) + self._check_required_fields(creation_schema, creation_settings, 'creation_settings') + except ValueError: + raise + except Exception as e: + self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}') + + # Validate retrieval_schema + try: + retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id) + self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings') + except ValueError: + raise + except Exception as e: + self.ap.logger.warning(f'Failed to get retrieval_schema for validation: {e}') + + def _check_required_fields( + self, + schema: dict | list, + settings: dict, + context: str, + ) -> None: + """Check required fields in schema against provided settings. + + Args: + schema: Plugin-defined schema (can be list or dict with 'schema' key). + settings: User-provided settings values. + context: Context name for error messages (e.g., 'creation_settings'). + + Raises: + ValueError: If a required field is missing or empty. + """ + if not schema: + return + + # schema can be a list directly, or a dict with 'schema' key + items = schema if isinstance(schema, list) else schema.get('schema', []) + if not items: + return + + for item in items: + field_name = item.get('name') + if not field_name: + continue + + is_required = item.get('required', False) + if not is_required: + continue + + # Check show_if condition - if field is conditionally shown, only validate when condition is met + show_if = item.get('show_if') + if show_if: + depend_field = show_if.get('field') + operator = show_if.get('operator') + expected_value = show_if.get('value') + + if depend_field and operator: + depend_value = settings.get(depend_field) + # If show_if condition is not met, skip validation for this field + if operator == 'eq' and depend_value != expected_value: + continue + if operator == 'neq' and depend_value == expected_value: + continue + if operator == 'in' and isinstance(expected_value, list) and depend_value not in expected_value: + continue + + value = settings.get(field_name) + + # Validate required field has a non-empty value + if value is None or (isinstance(value, str) and value.strip() == ''): + # Get field label for friendly error message + label = item.get('label', {}) + field_label = ( + label.get('en_US', field_name) + or label.get('zh_Hans', field_name) + or label.get('zh_Hant', field_name) + or field_name + ) + raise ValueError(f'{field_label} is required ({context}.{field_name})') + + async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None: + """更新知识库""" + # Filter to only mutable fields + filtered_data = {k: v for k, v in kb_data.items() if k in persistence_rag.KnowledgeBase.MUTABLE_FIELDS} + + if not filtered_data: + return + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_rag.KnowledgeBase) + .values(filtered_data) + .where(persistence_rag.KnowledgeBase.uuid == kb_uuid) + ) + await self.ap.rag_mgr.remove_knowledge_base_from_runtime(kb_uuid) + + kb = await self.get_knowledge_base(kb_uuid) + if kb is None: + raise Exception('Knowledge base not found after update') + + await self.ap.rag_mgr.load_knowledge_base(kb) + + async def _check_doc_capability(self, kb_uuid: str, operation: str) -> None: + """Check if the KB's Knowledge Engine supports document operations. + + Args: + kb_uuid: Knowledge base UUID. + operation: Human-readable operation name for error messages. + + Raises: + Exception: If the KB does not support doc_ingestion. + """ + kb_info = await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid) + if not kb_info: + raise Exception('Knowledge base not found') + capabilities = kb_info.get('knowledge_engine', {}).get('capabilities', []) + if 'doc_ingestion' not in capabilities: + raise Exception(f'This knowledge base does not support {operation}') + + async def store_file(self, kb_uuid: str, file_id: str, parser_plugin_id: str | None = None) -> str: + """存储文件""" + runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) + if runtime_kb is None: + raise Exception('Knowledge base not found') + + await self._check_doc_capability(kb_uuid, 'document upload') + + result = await runtime_kb.store_file(file_id, parser_plugin_id=parser_plugin_id) + + # Update the KB's updated_at timestamp + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_rag.KnowledgeBase) + .values(updated_at=sqlalchemy.func.now()) + .where(persistence_rag.KnowledgeBase.uuid == kb_uuid) + ) + + return result + + async def retrieve_knowledge_base( + self, kb_uuid: str, query: str, retrieval_settings: dict | None = None + ) -> list[dict]: + """检索知识库""" + runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) + if runtime_kb is None: + raise Exception('Knowledge base not found') + + # Pass retrieval_settings + results = await runtime_kb.retrieve(query, settings=retrieval_settings) + + return [result.model_dump() for result in results] + + async def get_files_by_knowledge_base(self, kb_uuid: str) -> list[dict]: + """获取知识库文件""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid) + ) + files = result.all() + return [self.ap.persistence_mgr.serialize_model(persistence_rag.File, file) for file in files] + + async def delete_file(self, kb_uuid: str, file_id: str) -> None: + """删除文件""" + runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) + if runtime_kb is None: + raise Exception('Knowledge base not found') + + await self._check_doc_capability(kb_uuid, 'document deletion') + + await runtime_kb.delete_file(file_id) + + # Update the KB's updated_at timestamp + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_rag.KnowledgeBase) + .values(updated_at=sqlalchemy.func.now()) + .where(persistence_rag.KnowledgeBase.uuid == kb_uuid) + ) + + async def delete_knowledge_base(self, kb_uuid: str) -> None: + """删除知识库""" + # Delete from DB first to commit the deletion, then clean up runtime/plugin (best-effort) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid) + ) + + # delete files + # NOTE: Chunk cleanup is for legacy (pre-plugin) KBs that stored chunks locally. + # For plugin-based Knowledge Engines, the Chunk table is not populated, so this is a no-op. + files = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid) + ) + for file in files: + # delete chunks + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_rag.Chunk).where(persistence_rag.Chunk.file_id == file.uuid) + ) + # delete file + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file.uuid) + ) + + # Remove from runtime and notify plugin (best-effort, DB is already cleaned up) + await self.ap.rag_mgr.delete_knowledge_base(kb_uuid) + + # ================= Knowledge Engine Discovery ================= + + async def list_knowledge_engines(self) -> list[dict]: + """List all available Knowledge Engines from plugins.""" + engines = [] + + if not self.ap.plugin_connector.is_enable_plugin: + return engines + + # Get KnowledgeEngine plugins + try: + knowledge_engines = await self.ap.plugin_connector.list_knowledge_engines() + engines.extend(knowledge_engines) + except Exception as e: + self.ap.logger.warning(f'Failed to list Knowledge Engines from plugins: {e}') + + return engines + + async def list_parsers(self, mime_type: str | None = None) -> list[dict]: + """List available parsers, optionally filtered by MIME type.""" + if not self.ap.plugin_connector.is_enable_plugin: + return [] + try: + parsers = await self.ap.plugin_connector.list_parsers() + if mime_type: + parsers = [p for p in parsers if mime_type in p.get('supported_mime_types', [])] + return parsers + except Exception as e: + self.ap.logger.warning(f'Failed to list parsers: {e}') + return [] + + async def get_engine_creation_schema(self, plugin_id: str) -> dict: + """Get creation settings schema for a specific Knowledge Engine.""" + try: + return await self.ap.plugin_connector.get_rag_creation_schema(plugin_id) + except Exception as e: + self.ap.logger.warning(f'Failed to get creation schema for {plugin_id}: {e}') + return {} + + async def get_engine_retrieval_schema(self, plugin_id: str) -> dict: + """Get retrieval settings schema for a specific Knowledge Engine.""" + try: + return await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id) + except Exception as e: + self.ap.logger.warning(f'Failed to get retrieval schema for {plugin_id}: {e}') + return {} diff --git a/src/langbot/pkg/api/http/service/maintenance.py b/src/langbot/pkg/api/http/service/maintenance.py new file mode 100644 index 0000000..fa7359c --- /dev/null +++ b/src/langbot/pkg/api/http/service/maintenance.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import datetime +import os +import re +from pathlib import Path +from typing import Any + +import sqlalchemy + +from ....core import app +from ....entity.persistence import bstorage as persistence_bstorage +from ....entity.persistence import monitoring as persistence_monitoring + + +LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$') +DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7 +DEFAULT_LOG_RETENTION_DAYS = 3 + + +class MaintenanceService: + """Storage maintenance and diagnostics.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def cleanup_expired_files(self) -> dict[str, int]: + cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {}) + upload_retention_days = self._positive_int( + cleanup_cfg.get('uploaded_file_retention_days'), + DEFAULT_UPLOAD_FILE_RETENTION_DAYS, + 'storage.cleanup.uploaded_file_retention_days', + ) + log_retention_days = self._positive_int( + cleanup_cfg.get('log_retention_days'), + DEFAULT_LOG_RETENTION_DAYS, + 'storage.cleanup.log_retention_days', + ) + + return { + 'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days), + 'log_files': self._cleanup_expired_log_files(log_retention_days), + } + + async def get_storage_analysis(self) -> dict[str, Any]: + cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {}) + upload_retention_days = self._positive_int( + cleanup_cfg.get('uploaded_file_retention_days'), + DEFAULT_UPLOAD_FILE_RETENTION_DAYS, + 'storage.cleanup.uploaded_file_retention_days', + ) + log_retention_days = self._positive_int( + cleanup_cfg.get('log_retention_days'), + DEFAULT_LOG_RETENTION_DAYS, + 'storage.cleanup.log_retention_days', + ) + + database_cfg = self.ap.instance_config.data.get('database', {}) + database_type = database_cfg.get('use', 'sqlite') + database_path = ( + Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None + ) + roots: list[tuple[str, Path | None]] = [ + ('database', database_path), + ('logs', Path('data/logs')), + ('storage', Path('data/storage')), + ('vector_store', Path('data/chroma')), + ('plugins', Path('data/plugins')), + ('mcp', Path('data/mcp')), + ('temp', Path('data/temp')), + ] + + sections = [] + for key, path in roots: + sections.append( + { + 'key': key, + 'path': str(path) if path else '', + 'exists': path.exists() if path else False, + 'size_bytes': self._path_size(path) if path else 0, + 'file_count': self._file_count(path) if path else 0, + } + ) + + monitoring_counts = await self._monitoring_counts() + binary_storage = await self._binary_storage_stats() + upload_candidates = await self._expired_uploaded_candidates(upload_retention_days) + log_candidates = self._expired_log_candidates(log_retention_days) + + return { + 'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'cleanup_policy': { + 'uploaded_file_retention_days': upload_retention_days, + 'log_retention_days': log_retention_days, + }, + 'sections': sections, + 'database': { + 'type': database_type, + 'monitoring_counts': monitoring_counts, + 'binary_storage': binary_storage, + }, + 'cleanup_candidates': { + 'uploaded_files': upload_candidates, + 'log_files': log_candidates, + }, + 'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {}, + } + + async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int: + provider = self.ap.storage_mgr.storage_provider + provider_name = provider.__class__.__name__ + if provider_name == 'LocalStorageProvider': + candidates = self._expired_local_upload_candidates(retention_days, include_paths=True) + deleted = 0 + for item in candidates: + try: + os.remove(item['path']) + deleted += 1 + except FileNotFoundError: + pass + except Exception as e: + self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}') + return deleted + + if provider_name == 'S3StorageProvider': + return await self._cleanup_expired_s3_uploaded_files(retention_days) + + return 0 + + async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]: + provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__ + if provider_name == 'LocalStorageProvider': + return self._expired_local_upload_candidates(retention_days) + if provider_name == 'S3StorageProvider': + return await self._expired_s3_upload_candidates(retention_days) + return [] + + async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int: + provider = self.ap.storage_mgr.storage_provider + candidates = await self._expired_s3_upload_candidates(retention_days) + deleted = 0 + for item in candidates: + await provider.delete(item['key']) + deleted += 1 + return deleted + + async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]: + provider = self.ap.storage_mgr.storage_provider + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days) + candidates = [] + paginator = provider.s3_client.get_paginator('list_objects_v2') + + for page in paginator.paginate(Bucket=provider.bucket_name): + for obj in page.get('Contents', []): + key = obj.get('Key', '') + last_modified = obj.get('LastModified') + if not self._is_uploaded_file_key(key): + continue + if last_modified and last_modified < cutoff: + candidates.append( + { + 'key': key, + 'size_bytes': obj.get('Size', 0), + 'modified_at': last_modified.isoformat(), + } + ) + + return candidates + + def _cleanup_expired_log_files(self, retention_days: int) -> int: + deleted = 0 + for item in self._expired_log_candidates(retention_days, include_paths=True): + try: + os.remove(item['path']) + deleted += 1 + except FileNotFoundError: + pass + except Exception as e: + self.ap.logger.warning(f'Failed to delete expired log file {item["name"]}: {e}') + return deleted + + def _expired_local_upload_candidates( + self, retention_days: int, include_paths: bool = False + ) -> list[dict[str, Any]]: + storage_root = Path('data/storage') + if not storage_root.exists(): + return [] + + cutoff = datetime.datetime.now().timestamp() - retention_days * 86400 + candidates = [] + for entry in storage_root.iterdir(): + if not entry.is_file() or not self._is_uploaded_file_key(entry.name): + continue + stat = entry.stat() + if stat.st_mtime >= cutoff: + continue + item = { + 'key': entry.name, + 'size_bytes': stat.st_size, + 'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(), + } + if include_paths: + item['path'] = str(entry) + candidates.append(item) + return candidates + + def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]: + log_root = Path('data/logs') + if not log_root.exists(): + return [] + + cutoff_date = datetime.date.today() - datetime.timedelta(days=retention_days - 1) + candidates = [] + for entry in log_root.iterdir(): + if not entry.is_file(): + continue + match = LOG_FILE_PATTERN.match(entry.name) + if not match: + continue + try: + file_date = datetime.date.fromisoformat(match.group(1)) + except ValueError: + continue + if file_date >= cutoff_date: + continue + stat = entry.stat() + item = { + 'name': entry.name, + 'date': file_date.isoformat(), + 'size_bytes': stat.st_size, + } + if include_paths: + item['path'] = str(entry) + candidates.append(item) + return candidates + + def _is_uploaded_file_key(self, key: str) -> bool: + return '/' not in key and not key.startswith('plugin_config_') + + async def _monitoring_counts(self) -> dict[str, int]: + tables = { + 'messages': persistence_monitoring.MonitoringMessage.id, + 'llm_calls': persistence_monitoring.MonitoringLLMCall.id, + 'tool_calls': persistence_monitoring.MonitoringToolCall.id, + 'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id, + 'errors': persistence_monitoring.MonitoringError.id, + 'sessions': persistence_monitoring.MonitoringSession.session_id, + 'feedback': persistence_monitoring.MonitoringFeedback.id, + } + counts: dict[str, int] = {} + for key, column in tables.items(): + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column))) + counts[key] = result.scalar() or 0 + return counts + + async def _binary_storage_stats(self) -> dict[str, Any]: + count_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key)) + ) + size_bytes = None + try: + size_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value))) + ) + size_bytes = size_result.scalar() or 0 + except Exception as e: + self.ap.logger.warning(f'Failed to estimate binary storage size: {e}') + + return { + 'count': count_result.scalar() or 0, + 'size_bytes': size_bytes, + } + + def _path_size(self, path: Path) -> int: + if not path.exists(): + return 0 + if path.is_file(): + return path.stat().st_size + total = 0 + for root, _, files in os.walk(path): + for file_name in files: + file_path = Path(root) / file_name + try: + total += file_path.stat().st_size + except FileNotFoundError: + pass + return total + + def _file_count(self, path: Path) -> int: + if not path.exists(): + return 0 + if path.is_file(): + return 1 + count = 0 + for _, _, files in os.walk(path): + count += len(files) + return count + + def _positive_int(self, value: Any, default: int, name: str) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + if parsed < 1: + self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + return parsed diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py new file mode 100644 index 0000000..1dbceb6 --- /dev/null +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import sqlalchemy +import uuid +import asyncio + +from ....core import app +from ....entity.persistence import mcp as persistence_mcp +from ....core import taskmgr +from ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus + + +class MCPService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_runtime_info(self, server_name: str) -> dict | None: + session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if session: + return session.get_runtime_info_dict() + return None + + async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) + + servers = result.all() + serialized_servers = [ + self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers + ] + if contain_runtime_info: + for server in serialized_servers: + runtime_info = await self.get_runtime_info(server['name']) + + server['runtime_info'] = runtime_info if runtime_info else None + + return serialized_servers + + async def create_mcp_server(self, server_data: dict) -> str: + # Check limitation (extensions = MCP servers + plugins) + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + max_extensions = limitation.get('max_extensions', -1) + if max_extensions >= 0: + existing_mcp_servers = await self.get_mcp_servers() + plugins = await self.ap.plugin_connector.list_plugins() + total_extensions = len(existing_mcp_servers) + len(plugins) + if total_extensions >= max_extensions: + raise ValueError(f'Maximum number of extensions ({max_extensions}) reached') + + server_name = str(server_data.get('name') or '').strip() + if not server_name: + raise ValueError('MCP server name is required') + server_data['name'] = server_name + + existing_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name) + ) + if existing_result.first() is not None: + raise ValueError(f'MCP server already exists: {server_name}') + + server_data['uuid'] = str(uuid.uuid4()) + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid']) + ) + server_entity = result.first() + if server_entity: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity) + if self.ap.tool_mgr.mcp_tool_loader: + task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) + + return server_data['uuid'] + + async def get_mcp_server_by_name(self, server_name: str) -> dict | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name) + ) + server = result.first() + if server is None: + return None + + runtime_info = await self.get_runtime_info(server.name) + server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + server_data['runtime_info'] = runtime_info if runtime_info else None + return server_data + + async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + old_server = result.first() + old_server_name = old_server.name if old_server else None + old_enable = old_server.enable if old_server else False + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_mcp.MCPServer) + .where(persistence_mcp.MCPServer.uuid == server_uuid) + .values(server_data) + ) + + if self.ap.tool_mgr.mcp_tool_loader: + new_enable = server_data.get('enable', False) + + need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions + + if old_enable and not new_enable: + if need_remove: + await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) + + elif not old_enable and new_enable: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + updated_server = result.first() + if updated_server: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) + task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) + + elif old_enable and new_enable: + if need_remove: + await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + updated_server = result.first() + if updated_server: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) + task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) + + async def delete_mcp_server(self, server_uuid: str) -> None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + server = result.first() + server_name = server.name if server else None + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + + if server_name and self.ap.tool_mgr.mcp_tool_loader: + if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: + await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name) + + async def get_mcp_server_resources(self, server_name: str) -> list[dict]: + """Get resources from a specific MCP server.""" + return await self.ap.tool_mgr.mcp_tool_loader.get_resources(server_name) + + async def get_mcp_server_resource_templates(self, server_name: str) -> list[dict]: + """Get resource templates from a specific MCP server.""" + return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(server_name) + + async def read_mcp_server_resource_envelope( + self, + server_name: str, + uri: str, + *, + max_bytes: int | None = None, + include_blob: bool = False, + ) -> dict: + """Read a resource from a specific MCP server with metadata.""" + kwargs = {'include_blob': include_blob, 'source': 'ui_preview'} + if max_bytes is not None: + kwargs['max_bytes'] = max_bytes + return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(server_name, uri, **kwargs) + + async def read_mcp_server_resource(self, server_name: str, uri: str) -> list[dict]: + """Read a resource from a specific MCP server.""" + return await self.ap.tool_mgr.mcp_tool_loader.read_resource(server_name, uri) + + async def test_mcp_server(self, server_name: str, server_data: dict) -> int: + """测试 MCP 服务器连接并返回任务 ID""" + + runtime_mcp_session: RuntimeMCPSession | None = None + + ctx = taskmgr.TaskContext.new() + + if server_name != '_': + runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if runtime_mcp_session is None: + raise ValueError(f'Server not found: {server_name}') + + persisted_session = runtime_mcp_session + + async def _refresh_and_report() -> None: + # Testing a persisted server should REUSE its live shared-session + # process, not rebuild it. Try a lightweight refresh (a real + # list_tools probe over the existing connection) first; only fall + # back to a full start() when the session has no live connection + # to probe (never connected, or the process is actually gone). + needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None + if needs_start: + await persisted_session.start() + else: + try: + await persisted_session.refresh() + except Exception: + # The live connection was stale/dropped: reconnect once + # (reusing the live managed process where possible) and + # re-probe, instead of reporting a false failure. + await persisted_session.start() + # Surface the discovered tools so the config page can render them + # even for an already-hosted server. + ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict() + + coroutine = _refresh_and_report() + else: + runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data) + + # A transient test owns an isolated Box session. Always tear it down + # after the test completes (success or failure) so it does not leak. + test_session = runtime_mcp_session + + async def _run_and_cleanup() -> None: + try: + await test_session.start() + # Capture the runtime info (status + discovered tools) BEFORE + # shutting the transient session down. The create/edit config + # page has no persisted server to reload from, so without this + # a successful test could only show "no tools found". The + # frontend reads ctx.metadata.runtime_info to render the tools. + ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict() + finally: + try: + await test_session.shutdown() + except Exception as exc: + self.ap.logger.warning( + f'Failed to tear down transient MCP test session ' + f'{test_session.server_name}: {type(exc).__name__}: {exc}' + ) + + coroutine = _run_and_cleanup() + + wrapper = self.ap.task_mgr.create_user_task( + coroutine, + kind='mcp-operation', + name=f'mcp-test-{server_name}', + label=f'Testing MCP server {server_name}', + context=ctx, + ) + return wrapper.id + + async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]: + """Get recent log lines captured from the MCP server's stderr.""" + session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if not session: + return [] + + # Get logs from the session's buffer + logs = list(session._log_buffer) + + # Filter by level if specified + if level: + logs = [log for log in logs if log.get('level') == level] + + # Return the most recent 'limit' logs + return logs[-limit:] diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py new file mode 100644 index 0000000..87298c0 --- /dev/null +++ b/src/langbot/pkg/api/http/service/model.py @@ -0,0 +1,585 @@ +from __future__ import annotations + +import uuid + +import sqlalchemy +from langbot_plugin.api.entities.builtin.provider import message as provider_message + +from ....core import app +from ....entity.persistence import model as persistence_model +from ....entity.persistence import pipeline as persistence_pipeline +from ....provider.modelmgr import requester as model_requester + + +def _parse_provider_api_keys(provider_dict: dict) -> dict: + """Parse api_keys if it's a JSON string""" + if isinstance(provider_dict.get('api_keys'), str): + import json + + try: + provider_dict['api_keys'] = json.loads(provider_dict['api_keys']) + except Exception: + provider_dict['api_keys'] = [] + return provider_dict + + +def _runtime_model_data(model_uuid: str, model_data: dict) -> dict: + """Return model data for rebuilding runtime models after an update. + + Update payloads intentionally omit uuid before writing to the database. + Runtime model entities still need the stable uuid so pipeline configs can + resolve the in-memory model immediately after an edit, without requiring a + process restart. + """ + return {**model_data, 'uuid': model_uuid} + + +async def _validate_provider_supports(ap: app.Application, provider_uuid: str, model_type: str) -> None: + """Validate that the provider's requester declares support for ``model_type``. + + ``model_type`` is one of the manifest ``support_type`` values: + 'llm', 'text-embedding', 'rerank'. Raises ValueError when the requester + manifest does not list the requested type. This is a server-side guard so + a model cannot be attached to a provider that does not support it, even if + the frontend tab restriction is bypassed. + """ + model_mgr = getattr(ap, 'model_mgr', None) + if model_mgr is None: + return + + provider_dict = getattr(model_mgr, 'provider_dict', None) + if not provider_dict: + return + runtime_provider = provider_dict.get(provider_uuid) + if runtime_provider is None: + return + + requester_name = getattr(getattr(runtime_provider, 'provider_entity', None), 'requester', None) + if not requester_name: + return + + get_manifest = getattr(model_mgr, 'get_available_requester_manifest_by_name', None) + if not callable(get_manifest): + return + manifest = get_manifest(requester_name) + if manifest is None: + return + + spec = getattr(manifest, 'spec', None) or {} + support_type = spec.get('support_type') if isinstance(spec, dict) else None + # When a manifest omits support_type, do not block (backward compatible). + if not support_type: + return + if model_type not in support_type: + raise ValueError(f'Provider requester "{requester_name}" does not support {model_type} models') + + +class LLMModelsService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_llm_models(self, include_secret: bool = True) -> list[dict]: + """Get all LLM models with provider info""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel)) + models = result.all() + + # Get all providers for lookup + providers_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.ModelProvider) + ) + providers = {p.uuid: p for p in providers_result.all()} + + models_list = [] + for model in models: + model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model) + provider = providers.get(model.provider_uuid) + if provider: + provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) + provider_dict = _parse_provider_api_keys(provider_dict) + if not include_secret: + provider_dict['api_keys'] = ['***'] * len(provider_dict.get('api_keys', [])) + model_dict['provider'] = provider_dict + models_list.append(model_dict) + + return models_list + + async def get_llm_models_by_provider(self, provider_uuid: str) -> list[dict]: + """Get LLM models by provider UUID""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.LLMModel).where( + persistence_model.LLMModel.provider_uuid == provider_uuid + ) + ) + models = result.all() + return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models] + + async def create_llm_model( + self, model_data: dict, preserve_uuid: bool = False, auto_set_to_default_pipeline: bool = True + ) -> str: + """Create a new LLM model""" + if not preserve_uuid: + model_data['uuid'] = str(uuid.uuid4()) + + # Handle provider creation if needed + if 'provider' in model_data: + provider_data = model_data.pop('provider') + if provider_data.get('uuid'): + model_data['provider_uuid'] = provider_data['uuid'] + else: + # Create new provider + provider_uuid = await self.ap.provider_service.find_or_create_provider( + requester=provider_data.get('requester', ''), + base_url=provider_data.get('base_url', ''), + api_keys=provider_data.get('api_keys', []), + ) + model_data['provider_uuid'] = provider_uuid + + await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'llm') + + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data)) + + runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) + if runtime_provider is None: + raise Exception('provider not found') + + runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider( + persistence_model.LLMModel(**model_data), + runtime_provider, + ) + self.ap.model_mgr.llm_models.append(runtime_llm_model) + + if auto_set_to_default_pipeline: + # set the default pipeline model to this model + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.is_default == True + ) + ) + pipeline = result.first() + if pipeline is not None: + model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {}) + if not model_config.get('primary', ''): + pipeline_config = pipeline.config + pipeline_config['ai']['local-agent']['model'] = { + 'primary': model_data['uuid'], + 'fallbacks': [], + } + pipeline_data = {'config': pipeline_config} + await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data) + + return model_data['uuid'] + + async def get_llm_model(self, model_uuid: str) -> dict | None: + """Get a single LLM model with provider info""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid) + ) + model = result.first() + if model is None: + return None + + model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model) + + # Get provider + provider_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.uuid == model.provider_uuid + ) + ) + provider = provider_result.first() + if provider: + provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) + model_dict['provider'] = _parse_provider_api_keys(provider_dict) + + return model_dict + + async def update_llm_model(self, model_uuid: str, model_data: dict) -> None: + """Update an existing LLM model""" + if 'uuid' in model_data: + del model_data['uuid'] + + # Handle provider update if needed + if 'provider' in model_data: + provider_data = model_data.pop('provider') + if provider_data.get('uuid'): + model_data['provider_uuid'] = provider_data['uuid'] + else: + provider_uuid = await self.ap.provider_service.find_or_create_provider( + requester=provider_data.get('requester', ''), + base_url=provider_data.get('base_url', ''), + api_keys=provider_data.get('api_keys', []), + ) + model_data['provider_uuid'] = provider_uuid + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_model.LLMModel) + .where(persistence_model.LLMModel.uuid == model_uuid) + .values(**model_data) + ) + + await self.ap.model_mgr.remove_llm_model(model_uuid) + + runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) + if runtime_provider is None: + raise Exception('provider not found') + + runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider( + persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)), + runtime_provider, + ) + self.ap.model_mgr.llm_models.append(runtime_llm_model) + + async def delete_llm_model(self, model_uuid: str) -> None: + """Delete an LLM model""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid) + ) + await self.ap.model_mgr.remove_llm_model(model_uuid) + + async def test_llm_model(self, model_uuid: str, model_data: dict) -> None: + """Test an LLM model""" + runtime_llm_model: model_requester.RuntimeLLMModel | None = None + + if model_uuid != '_': + for model in self.ap.model_mgr.llm_models: + if model.model_entity.uuid == model_uuid: + runtime_llm_model = model + break + if runtime_llm_model is None: + raise Exception('model not found') + else: + runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(model_data) + + extra_args = model_data.get('extra_args', {}) + await runtime_llm_model.provider.invoke_llm( + query=None, + model=runtime_llm_model, + messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')], + funcs=[], + extra_args=extra_args, + ) + + +class EmbeddingModelsService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_embedding_models(self) -> list[dict]: + """Get all embedding models with provider info""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel)) + models = result.all() + + providers_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.ModelProvider) + ) + providers = {p.uuid: p for p in providers_result.all()} + + models_list = [] + for model in models: + model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model) + provider = providers.get(model.provider_uuid) + if provider: + provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) + model_dict['provider'] = _parse_provider_api_keys(provider_dict) + models_list.append(model_dict) + + return models_list + + async def get_embedding_models_by_provider(self, provider_uuid: str) -> list[dict]: + """Get embedding models by provider UUID""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.EmbeddingModel).where( + persistence_model.EmbeddingModel.provider_uuid == provider_uuid + ) + ) + models = result.all() + return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models] + + async def create_embedding_model(self, model_data: dict, preserve_uuid: bool = False) -> str: + """Create a new embedding model""" + if not preserve_uuid: + model_data['uuid'] = str(uuid.uuid4()) + + if 'provider' in model_data: + provider_data = model_data.pop('provider') + if provider_data.get('uuid'): + model_data['provider_uuid'] = provider_data['uuid'] + else: + provider_uuid = await self.ap.provider_service.find_or_create_provider( + requester=provider_data.get('requester', ''), + base_url=provider_data.get('base_url', ''), + api_keys=provider_data.get('api_keys', []), + ) + model_data['provider_uuid'] = provider_uuid + + await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'text-embedding') + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data) + ) + + runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) + if runtime_provider is None: + raise Exception('provider not found') + + runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider( + persistence_model.EmbeddingModel(**model_data), + runtime_provider, + ) + self.ap.model_mgr.embedding_models.append(runtime_embedding_model) + + return model_data['uuid'] + + async def get_embedding_model(self, model_uuid: str) -> dict | None: + """Get a single embedding model with provider info""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.EmbeddingModel).where( + persistence_model.EmbeddingModel.uuid == model_uuid + ) + ) + model = result.first() + if model is None: + return None + + model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model) + + provider_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.uuid == model.provider_uuid + ) + ) + provider = provider_result.first() + if provider: + provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) + model_dict['provider'] = _parse_provider_api_keys(provider_dict) + + return model_dict + + async def update_embedding_model(self, model_uuid: str, model_data: dict) -> None: + """Update an existing embedding model""" + if 'uuid' in model_data: + del model_data['uuid'] + + if 'provider' in model_data: + provider_data = model_data.pop('provider') + if provider_data.get('uuid'): + model_data['provider_uuid'] = provider_data['uuid'] + else: + provider_uuid = await self.ap.provider_service.find_or_create_provider( + requester=provider_data.get('requester', ''), + base_url=provider_data.get('base_url', ''), + api_keys=provider_data.get('api_keys', []), + ) + model_data['provider_uuid'] = provider_uuid + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_model.EmbeddingModel) + .where(persistence_model.EmbeddingModel.uuid == model_uuid) + .values(**model_data) + ) + + await self.ap.model_mgr.remove_embedding_model(model_uuid) + + runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) + if runtime_provider is None: + raise Exception('provider not found') + + runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider( + persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)), + runtime_provider, + ) + self.ap.model_mgr.embedding_models.append(runtime_embedding_model) + + async def delete_embedding_model(self, model_uuid: str) -> None: + """Delete an embedding model""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_model.EmbeddingModel).where( + persistence_model.EmbeddingModel.uuid == model_uuid + ) + ) + await self.ap.model_mgr.remove_embedding_model(model_uuid) + + async def test_embedding_model(self, model_uuid: str, model_data: dict) -> None: + """Test an embedding model""" + runtime_embedding_model: model_requester.RuntimeEmbeddingModel | None = None + + if model_uuid != '_': + for model in self.ap.model_mgr.embedding_models: + if model.model_entity.uuid == model_uuid: + runtime_embedding_model = model + break + if runtime_embedding_model is None: + raise Exception('model not found') + else: + runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(model_data) + + await runtime_embedding_model.provider.invoke_embedding( + model=runtime_embedding_model, + input_text=['Hello, world!'], + extra_args={}, + ) + + +class RerankModelsService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_rerank_models(self) -> list[dict]: + """Get all rerank models with provider info""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel)) + models = result.all() + + providers_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.ModelProvider) + ) + providers = {p.uuid: p for p in providers_result.all()} + + models_list = [] + for model in models: + model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model) + provider = providers.get(model.provider_uuid) + if provider: + provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) + model_dict['provider'] = _parse_provider_api_keys(provider_dict) + models_list.append(model_dict) + + return models_list + + async def get_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]: + """Get rerank models by provider UUID""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.RerankModel).where( + persistence_model.RerankModel.provider_uuid == provider_uuid + ) + ) + models = result.all() + return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models] + + async def create_rerank_model(self, model_data: dict, preserve_uuid: bool = False) -> str: + """Create a new rerank model""" + if not preserve_uuid: + model_data['uuid'] = str(uuid.uuid4()) + + if 'provider' in model_data: + provider_data = model_data.pop('provider') + if provider_data.get('uuid'): + model_data['provider_uuid'] = provider_data['uuid'] + else: + provider_uuid = await self.ap.provider_service.find_or_create_provider( + requester=provider_data.get('requester', ''), + base_url=provider_data.get('base_url', ''), + api_keys=provider_data.get('api_keys', []), + ) + model_data['provider_uuid'] = provider_uuid + + await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'rerank') + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_model.RerankModel).values(**model_data) + ) + + runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) + if runtime_provider is None: + raise Exception('provider not found') + + runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider( + persistence_model.RerankModel(**model_data), + runtime_provider, + ) + self.ap.model_mgr.rerank_models.append(runtime_rerank_model) + + return model_data['uuid'] + + async def get_rerank_model(self, model_uuid: str) -> dict | None: + """Get a single rerank model with provider info""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid) + ) + model = result.first() + if model is None: + return None + + model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model) + + provider_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.uuid == model.provider_uuid + ) + ) + provider = provider_result.first() + if provider: + provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) + model_dict['provider'] = _parse_provider_api_keys(provider_dict) + + return model_dict + + async def update_rerank_model(self, model_uuid: str, model_data: dict) -> None: + """Update an existing rerank model""" + if 'uuid' in model_data: + del model_data['uuid'] + + if 'provider' in model_data: + provider_data = model_data.pop('provider') + if provider_data.get('uuid'): + model_data['provider_uuid'] = provider_data['uuid'] + else: + provider_uuid = await self.ap.provider_service.find_or_create_provider( + requester=provider_data.get('requester', ''), + base_url=provider_data.get('base_url', ''), + api_keys=provider_data.get('api_keys', []), + ) + model_data['provider_uuid'] = provider_uuid + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_model.RerankModel) + .where(persistence_model.RerankModel.uuid == model_uuid) + .values(**model_data) + ) + + await self.ap.model_mgr.remove_rerank_model(model_uuid) + + runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid']) + if runtime_provider is None: + raise Exception('provider not found') + + runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider( + persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)), + runtime_provider, + ) + self.ap.model_mgr.rerank_models.append(runtime_rerank_model) + + async def delete_rerank_model(self, model_uuid: str) -> None: + """Delete a rerank model""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid) + ) + await self.ap.model_mgr.remove_rerank_model(model_uuid) + + async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None: + """Test a rerank model""" + runtime_rerank_model: model_requester.RuntimeRerankModel | None = None + + if model_uuid != '_': + for model in self.ap.model_mgr.rerank_models: + if model.model_entity.uuid == model_uuid: + runtime_rerank_model = model + break + if runtime_rerank_model is None: + raise Exception('model not found') + else: + runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data) + + await runtime_rerank_model.provider.invoke_rerank( + model=runtime_rerank_model, + query='What is artificial intelligence?', + documents=[ + 'Artificial intelligence is a branch of computer science.', + 'The weather is nice today.', + ], + ) diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py new file mode 100644 index 0000000..46a352a --- /dev/null +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -0,0 +1,1941 @@ +from __future__ import annotations + +import uuid +import datetime +import json +import sqlalchemy + +from ....core import app +from ....entity.persistence import monitoring as persistence_monitoring + + +class MonitoringService: + """Monitoring service""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + # ========== Cleanup Methods ========== + + async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]: + """Delete monitoring records older than the specified retention period. + + Args: + retention_days: Number of days to retain records. + batch_size: Maximum rows to delete per table batch. + + Returns: + A dict mapping table name to the number of deleted rows. + """ + if retention_days < 1: + raise ValueError('retention_days must be >= 1') + if batch_size < 1: + raise ValueError('batch_size must be >= 1') + + cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta( + days=retention_days + ) + + tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [ + ( + 'monitoring_messages', + persistence_monitoring.MonitoringMessage, + persistence_monitoring.MonitoringMessage.timestamp, + persistence_monitoring.MonitoringMessage.id, + ), + ( + 'monitoring_llm_calls', + persistence_monitoring.MonitoringLLMCall, + persistence_monitoring.MonitoringLLMCall.timestamp, + persistence_monitoring.MonitoringLLMCall.id, + ), + ( + 'monitoring_tool_calls', + persistence_monitoring.MonitoringToolCall, + persistence_monitoring.MonitoringToolCall.timestamp, + persistence_monitoring.MonitoringToolCall.id, + ), + ( + 'monitoring_embedding_calls', + persistence_monitoring.MonitoringEmbeddingCall, + persistence_monitoring.MonitoringEmbeddingCall.timestamp, + persistence_monitoring.MonitoringEmbeddingCall.id, + ), + ( + 'monitoring_errors', + persistence_monitoring.MonitoringError, + persistence_monitoring.MonitoringError.timestamp, + persistence_monitoring.MonitoringError.id, + ), + ( + 'monitoring_sessions', + persistence_monitoring.MonitoringSession, + persistence_monitoring.MonitoringSession.last_activity, + persistence_monitoring.MonitoringSession.session_id, + ), + ( + 'monitoring_feedback', + persistence_monitoring.MonitoringFeedback, + persistence_monitoring.MonitoringFeedback.timestamp, + persistence_monitoring.MonitoringFeedback.id, + ), + ] + + deleted_counts: dict[str, int] = {} + + for table_name, model_cls, ts_column, pk_column in tables_and_columns: + deleted_counts[table_name] = await self._delete_expired_in_batches( + model_cls=model_cls, + ts_column=ts_column, + pk_column=pk_column, + cutoff=cutoff, + batch_size=batch_size, + ) + + if sum(deleted_counts.values()) > 0: + await self._release_sqlite_space() + + return deleted_counts + + async def _delete_expired_in_batches( + self, + model_cls: type, + ts_column: sqlalchemy.Column, + pk_column: sqlalchemy.Column, + cutoff: datetime.datetime, + batch_size: int, + ) -> int: + deleted_total = 0 + + while True: + select_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size) + ) + pk_values = list(select_result.scalars().all()) + if not pk_values: + break + + delete_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values)) + ) + deleted = delete_result.rowcount or 0 + deleted_total += deleted + + if len(pk_values) < batch_size: + break + + return deleted_total + + async def _release_sqlite_space(self) -> None: + database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite') + if database_type != 'sqlite': + return + + async with self.ap.persistence_mgr.get_db_engine().connect() as conn: + autocommit_conn = await conn.execution_options(isolation_level='AUTOCOMMIT') + await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)')) + await autocommit_conn.execute(sqlalchemy.text('VACUUM')) + + def _serialize_tool_payload(self, payload: object, max_length: int = 20000) -> str | None: + """Serialize tool arguments/results for monitoring storage.""" + if payload is None: + return None + + if isinstance(payload, str): + text = payload + else: + try: + text = json.dumps(payload, ensure_ascii=False, default=str) + except Exception: + text = str(payload) + + if len(text) <= max_length: + return text + + return f'{text[:max_length]}... [truncated {len(text) - max_length} chars]' + + async def _get_message_for_tool_context( + self, + message_id: str | None = None, + session_id: str | None = None, + ): + if message_id: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_monitoring.MonitoringMessage).where( + persistence_monitoring.MonitoringMessage.id == message_id + ) + ) + row = result.first() + if row: + return row[0] + + if not session_id: + return None + + user_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringMessage) + .where( + sqlalchemy.and_( + persistence_monitoring.MonitoringMessage.session_id == session_id, + persistence_monitoring.MonitoringMessage.role == 'user', + ) + ) + .order_by(persistence_monitoring.MonitoringMessage.timestamp.desc()) + .limit(1) + ) + result = await self.ap.persistence_mgr.execute_async(user_query) + row = result.first() + if row: + return row[0] + + any_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringMessage) + .where(persistence_monitoring.MonitoringMessage.session_id == session_id) + .order_by(persistence_monitoring.MonitoringMessage.timestamp.desc()) + .limit(1) + ) + result = await self.ap.persistence_mgr.execute_async(any_query) + row = result.first() + return row[0] if row else None + + # ========== Recording Methods ========== + + async def record_message( + self, + bot_id: str, + bot_name: str, + pipeline_id: str, + pipeline_name: str, + message_content: str, + session_id: str, + status: str = 'success', + level: str = 'info', + platform: str | None = None, + user_id: str | None = None, + user_name: str | None = None, + runner_name: str | None = None, + variables: str | None = None, + role: str = 'user', + ) -> str: + """Record a message""" + message_id = str(uuid.uuid4()) + message_data = { + 'id': message_id, + 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + 'bot_id': bot_id, + 'bot_name': bot_name, + 'pipeline_id': pipeline_id, + 'pipeline_name': pipeline_name, + 'message_content': message_content, + 'session_id': session_id, + 'status': status, + 'level': level, + 'platform': platform, + 'user_id': user_id, + 'user_name': user_name, + 'runner_name': runner_name, + 'variables': variables, + 'role': role, + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_monitoring.MonitoringMessage).values(message_data) + ) + + return message_id + + async def record_llm_call( + self, + bot_id: str, + bot_name: str, + pipeline_id: str, + pipeline_name: str, + session_id: str, + model_name: str, + input_tokens: int, + output_tokens: int, + duration: int, + status: str = 'success', + cost: float | None = None, + error_message: str | None = None, + message_id: str | None = None, + ) -> str: + """Record an LLM call""" + call_id = str(uuid.uuid4()) + call_data = { + 'id': call_id, + 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + 'model_name': model_name, + 'input_tokens': input_tokens, + 'output_tokens': output_tokens, + 'total_tokens': input_tokens + output_tokens, + 'duration': duration, + 'cost': cost, + 'status': status, + 'bot_id': bot_id, + 'bot_name': bot_name, + 'pipeline_id': pipeline_id, + 'pipeline_name': pipeline_name, + 'session_id': session_id, + 'error_message': error_message, + 'message_id': message_id, + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_monitoring.MonitoringLLMCall).values(call_data) + ) + + return call_id + + async def record_tool_call( + self, + tool_name: str, + tool_source: str, + duration: int, + status: str = 'success', + bot_id: str | None = None, + bot_name: str | None = None, + pipeline_id: str | None = None, + pipeline_name: str | None = None, + session_id: str | None = None, + message_id: str | None = None, + arguments: object | None = None, + result: object | None = None, + error_message: str | None = None, + ) -> str: + """Record a tool call.""" + context_message = await self._get_message_for_tool_context(message_id=message_id, session_id=session_id) + if context_message: + bot_id = bot_id or context_message.bot_id + bot_name = bot_name or context_message.bot_name + pipeline_id = pipeline_id or context_message.pipeline_id + pipeline_name = pipeline_name or context_message.pipeline_name + session_id = session_id or context_message.session_id + message_id = message_id or context_message.id + + call_id = str(uuid.uuid4()) + call_data = { + 'id': call_id, + 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + 'tool_name': tool_name, + 'tool_source': tool_source, + 'duration': max(0, duration), + 'status': status, + 'bot_id': bot_id or 'unknown', + 'bot_name': bot_name or 'Unknown', + 'pipeline_id': pipeline_id or 'unknown', + 'pipeline_name': pipeline_name or 'Unknown', + 'session_id': session_id, + 'message_id': message_id, + 'arguments': self._serialize_tool_payload(arguments), + 'result': self._serialize_tool_payload(result), + 'error_message': self._serialize_tool_payload(error_message), + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_monitoring.MonitoringToolCall).values(call_data) + ) + + return call_id + + async def record_embedding_call( + self, + model_name: str, + prompt_tokens: int, + total_tokens: int, + duration: int, + input_count: int, + status: str = 'success', + error_message: str | None = None, + knowledge_base_id: str | None = None, + query_text: str | None = None, + session_id: str | None = None, + message_id: str | None = None, + call_type: str | None = None, + ) -> str: + """Record an embedding call""" + call_id = str(uuid.uuid4()) + call_data = { + 'id': call_id, + 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + 'model_name': model_name, + 'prompt_tokens': prompt_tokens, + 'total_tokens': total_tokens, + 'duration': duration, + 'input_count': input_count, + 'status': status, + 'error_message': error_message, + 'knowledge_base_id': knowledge_base_id, + 'query_text': query_text, + 'session_id': session_id, + 'message_id': message_id, + 'call_type': call_type, + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_monitoring.MonitoringEmbeddingCall).values(call_data) + ) + + return call_id + + async def record_session_start( + self, + session_id: str, + bot_id: str, + bot_name: str, + pipeline_id: str, + pipeline_name: str, + platform: str | None = None, + user_id: str | None = None, + user_name: str | None = None, + ) -> None: + """Record a new session""" + session_data = { + 'session_id': session_id, + 'bot_id': bot_id, + 'bot_name': bot_name, + 'pipeline_id': pipeline_id, + 'pipeline_name': pipeline_name, + 'message_count': 0, + 'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + 'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + 'is_active': True, + 'platform': platform, + 'user_id': user_id, + 'user_name': user_name, + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data) + ) + + async def update_session_activity( + self, + session_id: str, + pipeline_id: str | None = None, + pipeline_name: str | None = None, + ) -> bool: + """Update session last activity time and increment message count. + + Also updates pipeline info if the bot's pipeline has changed. + + Returns: + True if session was found and updated, False if session doesn't exist. + """ + update_values = { + 'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + 'message_count': persistence_monitoring.MonitoringSession.message_count + 1, + } + + # Update pipeline info if provided (handles pipeline switch) + if pipeline_id is not None: + update_values['pipeline_id'] = pipeline_id + if pipeline_name is not None: + update_values['pipeline_name'] = pipeline_name + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_monitoring.MonitoringSession) + .where(persistence_monitoring.MonitoringSession.session_id == session_id) + .values(update_values) + ) + # Check if any rows were updated + return result.rowcount > 0 + + async def record_error( + self, + bot_id: str, + bot_name: str, + pipeline_id: str, + pipeline_name: str, + error_type: str, + error_message: str, + session_id: str | None = None, + stack_trace: str | None = None, + message_id: str | None = None, + ) -> str: + """Record an error""" + error_id = str(uuid.uuid4()) + error_data = { + 'id': error_id, + 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + 'error_type': error_type, + 'error_message': error_message, + 'bot_id': bot_id, + 'bot_name': bot_name, + 'pipeline_id': pipeline_id, + 'pipeline_name': pipeline_name, + 'session_id': session_id, + 'stack_trace': stack_trace, + 'message_id': message_id, + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_monitoring.MonitoringError).values(error_data) + ) + + return error_id + + async def update_message_status( + self, + message_id: str, + status: str, + level: str | None = None, + variables: str | None = None, + ) -> None: + """Update message status and optionally variables""" + update_values = {'status': status} + if level is not None: + update_values['level'] = level + if variables is not None: + update_values['variables'] = variables + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_monitoring.MonitoringMessage) + .where(persistence_monitoring.MonitoringMessage.id == message_id) + .values(update_values) + ) + + # ========== Query Methods ========== + + async def get_overview_metrics( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + ) -> dict: + """Get overview metrics""" + # Build base query conditions + message_conditions = [] + llm_conditions = [] + embedding_conditions = [] + session_conditions = [] + + if bot_ids: + message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) + llm_conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids)) + session_conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids)) + + if pipeline_ids: + message_conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) + llm_conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids)) + session_conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) + + if start_time: + message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) + llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time) + embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time) + session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) + + if end_time: + message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time) + llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time) + embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time) + session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) + + # Total messages + message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)) + if message_conditions: + message_query = message_query.where(sqlalchemy.and_(*message_conditions)) + + total_messages_result = await self.ap.persistence_mgr.execute_async(message_query) + total_messages = total_messages_result.scalar() or 0 + + # Total LLM calls + llm_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id)) + if llm_conditions: + llm_query = llm_query.where(sqlalchemy.and_(*llm_conditions)) + + llm_calls_result = await self.ap.persistence_mgr.execute_async(llm_query) + llm_calls = llm_calls_result.scalar() or 0 + + # Total Embedding calls + embedding_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id)) + if embedding_conditions: + embedding_query = embedding_query.where(sqlalchemy.and_(*embedding_conditions)) + + embedding_calls_result = await self.ap.persistence_mgr.execute_async(embedding_query) + embedding_calls = embedding_calls_result.scalar() or 0 + + # Total model calls (LLM + Embedding) + model_calls = llm_calls + embedding_calls + + # Success rate (based on messages) + success_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)).where( + persistence_monitoring.MonitoringMessage.status == 'success' + ) + if message_conditions: + success_query = success_query.where(sqlalchemy.and_(*message_conditions)) + + success_result = await self.ap.persistence_mgr.execute_async(success_query) + success_count = success_result.scalar() or 0 + success_rate = (success_count / total_messages * 100) if total_messages > 0 else 100 + + # Active sessions + active_session_query = sqlalchemy.select( + sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id) + ).where(persistence_monitoring.MonitoringSession.is_active == True) + if session_conditions: + active_session_query = active_session_query.where(sqlalchemy.and_(*session_conditions)) + + active_sessions_result = await self.ap.persistence_mgr.execute_async(active_session_query) + active_sessions = active_sessions_result.scalar() or 0 + + return { + 'total_messages': total_messages, + 'llm_calls': llm_calls, + 'embedding_calls': embedding_calls, + 'model_calls': model_calls, + 'success_rate': round(success_rate, 2), + 'active_sessions': active_sessions, + } + + async def get_token_statistics( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + bucket: str = 'hour', + ) -> dict: + """Get detailed token usage statistics for production observability. + + Returns: + - summary: aggregate token counters and call/latency stats over the window + - by_model: per-model token + call breakdown (sorted by total tokens desc) + - timeseries: token usage bucketed by `bucket` ('hour' or 'day') + + Only successful LLM calls are counted toward token totals; error calls are + reported separately so a spike in failures is visible without polluting + token accounting. + """ + LLMCall = persistence_monitoring.MonitoringLLMCall + + conditions = [] + if bot_ids: + conditions.append(LLMCall.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(LLMCall.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(LLMCall.timestamp >= start_time) + if end_time: + conditions.append(LLMCall.timestamp <= end_time) + + def _apply(query): + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + return query + + # ---- Summary aggregates ---- + summary_query = _apply( + sqlalchemy.select( + sqlalchemy.func.count(LLMCall.id), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0), + sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'success', 1), else_=0)), + sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)), + # Count of successful calls that nonetheless recorded zero tokens — + # a data-quality signal that usage reporting may be broken upstream. + sqlalchemy.func.sum( + sqlalchemy.case( + (sqlalchemy.and_(LLMCall.status == 'success', LLMCall.total_tokens == 0), 1), + else_=0, + ) + ), + ) + ) + summary_result = await self.ap.persistence_mgr.execute_async(summary_query) + row = summary_result.first() + ( + total_calls, + total_input_tokens, + total_output_tokens, + total_tokens, + total_duration, + total_cost, + success_calls, + error_calls, + zero_token_success_calls, + ) = row if row else (0, 0, 0, 0, 0, 0.0, 0, 0, 0) + + total_calls = total_calls or 0 + success_calls = success_calls or 0 + error_calls = error_calls or 0 + zero_token_success_calls = zero_token_success_calls or 0 + + summary = { + 'total_calls': total_calls, + 'success_calls': success_calls, + 'error_calls': error_calls, + 'total_input_tokens': int(total_input_tokens or 0), + 'total_output_tokens': int(total_output_tokens or 0), + 'total_tokens': int(total_tokens or 0), + 'total_cost': round(float(total_cost or 0.0), 6), + 'avg_tokens_per_call': int((total_tokens or 0) / total_calls) if total_calls > 0 else 0, + 'avg_duration_ms': int((total_duration or 0) / total_calls) if total_calls > 0 else 0, + 'avg_tokens_per_second': round((total_output_tokens or 0) / (total_duration / 1000), 2) + if total_duration and total_duration > 0 + else 0, + 'zero_token_success_calls': zero_token_success_calls, + } + + # ---- Per-model breakdown ---- + by_model_query = _apply( + sqlalchemy.select( + LLMCall.model_name, + sqlalchemy.func.count(LLMCall.id), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0), + sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)), + ).group_by(LLMCall.model_name) + ) + by_model_result = await self.ap.persistence_mgr.execute_async(by_model_query) + by_model = [] + for mrow in by_model_result.all(): + ( + model_name, + m_calls, + m_in, + m_out, + m_total, + m_duration, + m_cost, + m_errors, + ) = mrow + m_calls = m_calls or 0 + by_model.append( + { + 'model_name': model_name, + 'calls': m_calls, + 'error_calls': m_errors or 0, + 'input_tokens': int(m_in or 0), + 'output_tokens': int(m_out or 0), + 'total_tokens': int(m_total or 0), + 'cost': round(float(m_cost or 0.0), 6), + 'avg_tokens_per_call': int((m_total or 0) / m_calls) if m_calls > 0 else 0, + 'avg_duration_ms': int((m_duration or 0) / m_calls) if m_calls > 0 else 0, + } + ) + by_model.sort(key=lambda x: x['total_tokens'], reverse=True) + + # ---- Time-bucketed series ---- + # Use a DB-agnostic bucketing approach: fetch (timestamp, tokens) rows and + # aggregate in Python. The window is bounded by the time filter, so this is + # cheap for typical dashboard ranges (hours/days). + series_query = _apply( + sqlalchemy.select( + LLMCall.timestamp, + LLMCall.input_tokens, + LLMCall.output_tokens, + LLMCall.total_tokens, + ).order_by(LLMCall.timestamp.asc()) + ) + series_result = await self.ap.persistence_mgr.execute_async(series_query) + + bucket_fmt = '%Y-%m-%d %H:00' if bucket == 'hour' else '%Y-%m-%d' + buckets: dict[str, dict] = {} + for srow in series_result.all(): + ts, s_in, s_out, s_total = srow + if ts is None: + continue + key = ts.strftime(bucket_fmt) + b = buckets.setdefault( + key, + {'bucket': key, 'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0, 'calls': 0}, + ) + b['input_tokens'] += int(s_in or 0) + b['output_tokens'] += int(s_out or 0) + b['total_tokens'] += int(s_total or 0) + b['calls'] += 1 + + timeseries = [buckets[k] for k in sorted(buckets.keys())] + + return { + 'summary': summary, + 'by_model': by_model, + 'timeseries': timeseries, + 'bucket': bucket, + } + + async def get_messages( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + session_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[dict], int]: + """Get messages with filters""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) + if session_ids: + conditions.append(persistence_monitoring.MonitoringMessage.session_id.in_(session_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time) + + # Get total count + count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)) + if conditions: + count_query = count_query.where(sqlalchemy.and_(*conditions)) + + count_result = await self.ap.persistence_mgr.execute_async(count_query) + total = count_result.scalar() or 0 + + # Get messages + query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by( + persistence_monitoring.MonitoringMessage.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit).offset(offset) + + result = await self.ap.persistence_mgr.execute_async(query) + messages_rows = result.all() + + serialized = [] + for row in messages_rows: + # Extract model instance from Row (SQLAlchemy returns Row objects) + msg = row[0] if isinstance(row, tuple) else row + serialized_msg = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, msg) + serialized.append(serialized_msg) + + return (serialized, total) + + async def get_llm_calls( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[dict], int]: + """Get LLM calls with filters""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time) + + # Get total count + count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id)) + if conditions: + count_query = count_query.where(sqlalchemy.and_(*conditions)) + + count_result = await self.ap.persistence_mgr.execute_async(count_query) + total = count_result.scalar() or 0 + + # Get LLM calls + query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by( + persistence_monitoring.MonitoringLLMCall.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit).offset(offset) + + result = await self.ap.persistence_mgr.execute_async(query) + llm_calls_rows = result.all() + + return ( + [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row + ) + for row in llm_calls_rows + ], + total, + ) + + async def get_tool_calls( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + session_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[dict], int]: + """Get tool calls with filters""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringToolCall.pipeline_id.in_(pipeline_ids)) + if session_ids: + conditions.append(persistence_monitoring.MonitoringToolCall.session_id.in_(session_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time) + + count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringToolCall.id)) + if conditions: + count_query = count_query.where(sqlalchemy.and_(*conditions)) + + count_result = await self.ap.persistence_mgr.execute_async(count_query) + total = count_result.scalar() or 0 + + query = sqlalchemy.select(persistence_monitoring.MonitoringToolCall).order_by( + persistence_monitoring.MonitoringToolCall.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit).offset(offset) + + result = await self.ap.persistence_mgr.execute_async(query) + tool_calls_rows = result.all() + + return ( + [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row + ) + for row in tool_calls_rows + ], + total, + ) + + async def get_embedding_calls( + self, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + knowledge_base_id: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[dict], int]: + """Get embedding calls with filters""" + conditions = [] + + if start_time: + conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time) + if knowledge_base_id: + conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id) + + # Get total count + count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id)) + if conditions: + count_query = count_query.where(sqlalchemy.and_(*conditions)) + + count_result = await self.ap.persistence_mgr.execute_async(count_query) + total = count_result.scalar() or 0 + + # Get embedding calls + query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by( + persistence_monitoring.MonitoringEmbeddingCall.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit).offset(offset) + + result = await self.ap.persistence_mgr.execute_async(query) + embedding_calls_rows = result.all() + + return ( + [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringEmbeddingCall, row[0] if isinstance(row, tuple) else row + ) + for row in embedding_calls_rows + ], + total, + ) + + async def get_sessions( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + is_active: bool | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[dict], int]: + """Get sessions with filters""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) + if is_active is not None: + conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active) + + # Get total count + count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id)) + if conditions: + count_query = count_query.where(sqlalchemy.and_(*conditions)) + + count_result = await self.ap.persistence_mgr.execute_async(count_query) + total = count_result.scalar() or 0 + + # Get sessions + query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by( + persistence_monitoring.MonitoringSession.last_activity.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit).offset(offset) + + result = await self.ap.persistence_mgr.execute_async(query) + sessions_rows = result.all() + + return ( + [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringSession, row[0] if isinstance(row, tuple) else row + ) + for row in sessions_rows + ], + total, + ) + + async def get_errors( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[dict], int]: + """Get errors with filters""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time) + + # Get total count + count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringError.id)) + if conditions: + count_query = count_query.where(sqlalchemy.and_(*conditions)) + + count_result = await self.ap.persistence_mgr.execute_async(count_query) + total = count_result.scalar() or 0 + + # Get errors + query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by( + persistence_monitoring.MonitoringError.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit).offset(offset) + + result = await self.ap.persistence_mgr.execute_async(query) + errors_rows = result.all() + + return ( + [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row + ) + for row in errors_rows + ], + total, + ) + + async def get_session_analysis( + self, + session_id: str, + ) -> dict: + """Get detailed analysis for a specific session""" + # Get session info + session_query = sqlalchemy.select(persistence_monitoring.MonitoringSession).where( + persistence_monitoring.MonitoringSession.session_id == session_id + ) + session_result = await self.ap.persistence_mgr.execute_async(session_query) + session_row = session_result.first() + + if not session_row: + return { + 'session_id': session_id, + 'found': False, + } + + session = session_row[0] if isinstance(session_row, tuple) else session_row + + # Get messages for this session + messages_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringMessage) + .where(persistence_monitoring.MonitoringMessage.session_id == session_id) + .order_by(persistence_monitoring.MonitoringMessage.timestamp.asc()) + ) + messages_result = await self.ap.persistence_mgr.execute_async(messages_query) + messages_rows = messages_result.all() + + # Count messages by status + success_messages = 0 + error_messages = 0 + pending_messages = 0 + for row in messages_rows: + msg = row[0] if isinstance(row, tuple) else row + if msg.status == 'success': + success_messages += 1 + elif msg.status == 'error': + error_messages += 1 + elif msg.status == 'pending': + pending_messages += 1 + + # Get LLM calls for this session + llm_query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).where( + persistence_monitoring.MonitoringLLMCall.session_id == session_id + ) + llm_result = await self.ap.persistence_mgr.execute_async(llm_query) + llm_rows = llm_result.all() + + # Calculate LLM statistics + total_llm_calls = len(llm_rows) + total_input_tokens = 0 + total_output_tokens = 0 + total_tokens = 0 + total_duration = 0 + success_llm_calls = 0 + error_llm_calls = 0 + + for row in llm_rows: + llm_call = row[0] if isinstance(row, tuple) else row + total_input_tokens += llm_call.input_tokens + total_output_tokens += llm_call.output_tokens + total_tokens += llm_call.total_tokens + total_duration += llm_call.duration + if llm_call.status == 'success': + success_llm_calls += 1 + else: + error_llm_calls += 1 + + # Get tool calls for this session + tool_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringToolCall) + .where(persistence_monitoring.MonitoringToolCall.session_id == session_id) + .order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc()) + ) + tool_result = await self.ap.persistence_mgr.execute_async(tool_query) + tool_rows = tool_result.all() + + tool_calls = [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row + ) + for row in tool_rows + ] + + total_tool_calls = len(tool_rows) + success_tool_calls = 0 + error_tool_calls = 0 + total_tool_duration = 0 + for row in tool_rows: + tool_call = row[0] if isinstance(row, tuple) else row + total_tool_duration += tool_call.duration + if tool_call.status == 'success': + success_tool_calls += 1 + else: + error_tool_calls += 1 + + # Get errors for this session + error_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringError) + .where(persistence_monitoring.MonitoringError.session_id == session_id) + .order_by(persistence_monitoring.MonitoringError.timestamp.desc()) + ) + error_result = await self.ap.persistence_mgr.execute_async(error_query) + error_rows = error_result.all() + + errors = [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row + ) + for row in error_rows + ] + + # Calculate session duration + if messages_rows: + first_msg = messages_rows[0][0] if isinstance(messages_rows[0], tuple) else messages_rows[0] + last_msg = messages_rows[-1][0] if isinstance(messages_rows[-1], tuple) else messages_rows[-1] + session_duration_seconds = int((last_msg.timestamp - first_msg.timestamp).total_seconds()) + else: + session_duration_seconds = 0 + + return { + 'session_id': session_id, + 'found': True, + 'session': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSession, session), + 'message_stats': { + 'total': len(messages_rows), + 'success': success_messages, + 'error': error_messages, + 'pending': pending_messages, + }, + 'llm_stats': { + 'total_calls': total_llm_calls, + 'success_calls': success_llm_calls, + 'error_calls': error_llm_calls, + 'total_input_tokens': total_input_tokens, + 'total_output_tokens': total_output_tokens, + 'total_tokens': total_tokens, + 'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0, + }, + 'tool_calls': tool_calls, + 'tool_stats': { + 'total_calls': total_tool_calls, + 'success_calls': success_tool_calls, + 'error_calls': error_tool_calls, + 'total_duration_ms': total_tool_duration, + 'average_duration_ms': int(total_tool_duration / total_tool_calls) if total_tool_calls > 0 else 0, + }, + 'errors': errors, + 'session_duration_seconds': session_duration_seconds, + } + + async def get_message_details( + self, + message_id: str, + ) -> dict: + """Get detailed information for a specific message including associated LLM calls and errors""" + # Get message info + message_query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).where( + persistence_monitoring.MonitoringMessage.id == message_id + ) + message_result = await self.ap.persistence_mgr.execute_async(message_query) + message_row = message_result.first() + + if not message_row: + return { + 'message_id': message_id, + 'found': False, + } + + message = message_row[0] if isinstance(message_row, tuple) else message_row + + # Get LLM calls for this message + llm_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringLLMCall) + .where(persistence_monitoring.MonitoringLLMCall.message_id == message_id) + .order_by(persistence_monitoring.MonitoringLLMCall.timestamp.asc()) + ) + llm_result = await self.ap.persistence_mgr.execute_async(llm_query) + llm_rows = llm_result.all() + + llm_calls = [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row + ) + for row in llm_rows + ] + + # Calculate LLM statistics + total_input_tokens = sum(call.input_tokens for call in llm_rows) + total_output_tokens = sum(call.output_tokens for call in llm_rows) + total_tokens = sum(call.total_tokens for call in llm_rows) + total_duration = sum(call.duration for call in llm_rows) + + # Get errors for this message + error_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringError) + .where(persistence_monitoring.MonitoringError.message_id == message_id) + .order_by(persistence_monitoring.MonitoringError.timestamp.asc()) + ) + error_result = await self.ap.persistence_mgr.execute_async(error_query) + error_rows = error_result.all() + + errors = [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row + ) + for row in error_rows + ] + + return { + 'message_id': message_id, + 'found': True, + 'message': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, message), + 'llm_calls': llm_calls, + 'llm_stats': { + 'total_calls': len(llm_rows), + 'total_input_tokens': total_input_tokens, + 'total_output_tokens': total_output_tokens, + 'total_tokens': total_tokens, + 'total_duration_ms': total_duration, + 'average_duration_ms': int(total_duration / len(llm_rows)) if len(llm_rows) > 0 else 0, + }, + 'errors': errors, + } + + # ========== Export Methods ========== + + def _escape_csv_field(self, field: str | None) -> str: + """Escape a field for CSV output""" + if field is None: + return '' + # Convert non-string types to string first + if not isinstance(field, str): + field = str(field) + # Replace common escape sequences + field = field.replace('\r\n', '\n').replace('\r', '\n') + # If field contains comma, double quote, or newline, wrap in quotes + if ',' in field or '"' in field or '\n' in field: + # Escape double quotes by doubling them + field = '"' + field.replace('"', '""') + '"' + return field + + def _format_timestamp(self, dt: datetime.datetime) -> str: + """Format datetime to ISO format string""" + return dt.strftime('%Y-%m-%d %H:%M:%S') + + def _extract_message_text(self, message_content: str) -> str: + """Extract plain text from message chain JSON""" + if not message_content: + return '' + + try: + import json + + message_chain = json.loads(message_content) + if not isinstance(message_chain, list): + return message_content + + text_parts = [] + for component in message_chain: + if not isinstance(component, dict): + continue + component_type = component.get('type') + if component_type == 'Plain': + text = component.get('text', '') + text_parts.append(text) + elif component_type == 'At': + display = component.get('display', '') + target = component.get('target', '') + if display: + text_parts.append(f'@{display}') + elif target: + text_parts.append(f'@{target}') + elif component_type == 'AtAll': + text_parts.append('@All') + elif component_type == 'Image': + text_parts.append('[Image]') + elif component_type == 'File': + name = component.get('name', 'File') + text_parts.append(f'[File: {name}]') + elif component_type == 'Voice': + length = component.get('length', 0) + text_parts.append(f'[Voice {length}s]') + elif component_type == 'Quote': + # Quote content is in 'origin' field + origin = component.get('origin', []) + if isinstance(origin, list): + for item in origin: + if isinstance(item, dict) and item.get('type') == 'Plain': + text_parts.append(f'> {item.get("text", "")}') + elif component_type == 'Source': + # Skip Source component + continue + else: + # Other unknown types + text_parts.append(f'[{component_type}]') + + return ''.join(text_parts) + except (json.JSONDecodeError, TypeError, KeyError): + # If not valid JSON, return as-is + return message_content + + async def export_messages( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export messages as list of dictionaries for CSV conversion""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time) + + query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by( + persistence_monitoring.MonitoringMessage.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'id': row[0].id if isinstance(row, tuple) else row.id, + 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), + 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, + 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, + 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, + 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, + 'runner_name': row[0].runner_name if isinstance(row, tuple) else row.runner_name, + 'message_content': row[0].message_content if isinstance(row, tuple) else row.message_content, + 'message_text': self._extract_message_text( + row[0].message_content if isinstance(row, tuple) else row.message_content + ), + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'status': row[0].status if isinstance(row, tuple) else row.status, + 'level': row[0].level if isinstance(row, tuple) else row.level, + 'platform': row[0].platform if isinstance(row, tuple) else row.platform, + 'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id, + } + for row in rows + ] + + async def export_llm_calls( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export LLM calls as list of dictionaries for CSV conversion""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time) + + query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by( + persistence_monitoring.MonitoringLLMCall.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'id': row[0].id if isinstance(row, tuple) else row.id, + 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), + 'model_name': row[0].model_name if isinstance(row, tuple) else row.model_name, + 'input_tokens': row[0].input_tokens if isinstance(row, tuple) else row.input_tokens, + 'output_tokens': row[0].output_tokens if isinstance(row, tuple) else row.output_tokens, + 'total_tokens': row[0].total_tokens if isinstance(row, tuple) else row.total_tokens, + 'duration_ms': row[0].duration if isinstance(row, tuple) else row.duration, + 'cost': row[0].cost if isinstance(row, tuple) else row.cost, + 'status': row[0].status if isinstance(row, tuple) else row.status, + 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, + 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, + 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, + 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id, + 'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message, + } + for row in rows + ] + + async def export_embedding_calls( + self, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + knowledge_base_id: str | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export embedding calls as list of dictionaries for CSV conversion""" + conditions = [] + + if start_time: + conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time) + if knowledge_base_id: + conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id) + + query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by( + persistence_monitoring.MonitoringEmbeddingCall.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'id': row[0].id if isinstance(row, tuple) else row.id, + 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), + 'model_name': row[0].model_name if isinstance(row, tuple) else row.model_name, + 'prompt_tokens': row[0].prompt_tokens if isinstance(row, tuple) else row.prompt_tokens, + 'total_tokens': row[0].total_tokens if isinstance(row, tuple) else row.total_tokens, + 'duration_ms': row[0].duration if isinstance(row, tuple) else row.duration, + 'input_count': row[0].input_count if isinstance(row, tuple) else row.input_count, + 'status': row[0].status if isinstance(row, tuple) else row.status, + 'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message, + 'knowledge_base_id': row[0].knowledge_base_id if isinstance(row, tuple) else row.knowledge_base_id, + 'query_text': row[0].query_text if isinstance(row, tuple) else row.query_text, + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id, + 'call_type': row[0].call_type if isinstance(row, tuple) else row.call_type, + } + for row in rows + ] + + async def export_errors( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export errors as list of dictionaries for CSV conversion""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time) + + query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by( + persistence_monitoring.MonitoringError.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'id': row[0].id if isinstance(row, tuple) else row.id, + 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), + 'error_type': row[0].error_type if isinstance(row, tuple) else row.error_type, + 'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message, + 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, + 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, + 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, + 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id, + 'stack_trace': row[0].stack_trace if isinstance(row, tuple) else row.stack_trace, + } + for row in rows + ] + + async def export_sessions( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export sessions as list of dictionaries for CSV conversion""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) + + query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by( + persistence_monitoring.MonitoringSession.last_activity.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, + 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, + 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, + 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, + 'message_count': row[0].message_count if isinstance(row, tuple) else row.message_count, + 'start_time': self._format_timestamp(row[0].start_time if isinstance(row, tuple) else row.start_time), + 'last_activity': self._format_timestamp( + row[0].last_activity if isinstance(row, tuple) else row.last_activity + ), + 'is_active': str(row[0].is_active if isinstance(row, tuple) else row.is_active), + 'platform': row[0].platform if isinstance(row, tuple) else row.platform, + 'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id, + } + for row in rows + ] + + # ========== Feedback Methods ========== + + async def record_feedback( + self, + feedback_id: str, + feedback_type: int, + feedback_content: str | None = None, + inaccurate_reasons: list[str] | None = None, + bot_id: str | None = None, + bot_name: str | None = None, + pipeline_id: str | None = None, + pipeline_name: str | None = None, + session_id: str | None = None, + message_id: str | None = None, + stream_id: str | None = None, + user_id: str | None = None, + platform: str | None = None, + ) -> str: + """Record user feedback (like/dislike) from AI Bot conversation. + + Args: + feedback_id: Unique feedback identifier from platform (e.g., WeChat Work) + feedback_type: 1 = like (thumbs up), 2 = dislike (thumbs down) + feedback_content: Optional user feedback text + inaccurate_reasons: List of reasons for inaccurate response (for dislike) + bot_id: Bot ID + bot_name: Bot name + pipeline_id: Pipeline ID + pipeline_name: Pipeline name + session_id: Session ID + message_id: Message ID + stream_id: Stream ID (for WeChat Work streaming messages) + user_id: User ID + platform: Platform name (e.g., 'wecom') + + Returns: + The record ID + """ + import json + + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None + + MonitoringFeedback = persistence_monitoring.MonitoringFeedback + + # Handle cancel feedback (type=3): delete existing record + if feedback_type == 3: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id) + ) + return None + + # Check if record with this feedback_id already exists + existing_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id) + ) + existing_row = existing_result.first() + + if existing_row: + # UPDATE existing record + existing = existing_row[0] if isinstance(existing_row, tuple) else existing_row + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(MonitoringFeedback) + .where(MonitoringFeedback.feedback_id == feedback_id) + .values( + timestamp=now, + feedback_type=feedback_type, + feedback_content=feedback_content, + inaccurate_reasons=reasons_json, + bot_id=bot_id or existing.bot_id, + bot_name=bot_name or existing.bot_name, + pipeline_id=pipeline_id or existing.pipeline_id, + pipeline_name=pipeline_name or existing.pipeline_name, + session_id=session_id or existing.session_id, + message_id=message_id or existing.message_id, + stream_id=stream_id or existing.stream_id, + user_id=user_id or existing.user_id, + platform=platform or existing.platform, + ) + ) + return existing.id + else: + # INSERT new record with IntegrityError defense + record_id = str(uuid.uuid4()) + record_data = { + 'id': record_id, + 'timestamp': now, + 'feedback_id': feedback_id, + 'feedback_type': feedback_type, + 'feedback_content': feedback_content, + 'inaccurate_reasons': reasons_json, + 'bot_id': bot_id, + 'bot_name': bot_name, + 'pipeline_id': pipeline_id, + 'pipeline_name': pipeline_name, + 'session_id': session_id, + 'message_id': message_id, + 'stream_id': stream_id, + 'user_id': user_id, + 'platform': platform, + } + try: + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringFeedback).values(record_data)) + return record_id + except Exception: + # UNIQUE constraint conflict (concurrent feedback for same feedback_id) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(MonitoringFeedback) + .where(MonitoringFeedback.feedback_id == feedback_id) + .values( + timestamp=now, + feedback_type=feedback_type, + feedback_content=feedback_content, + inaccurate_reasons=reasons_json, + ) + ) + return feedback_id + + async def get_feedback_stats( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + ) -> dict: + """Get feedback statistics. + + Returns: + Dictionary with total likes, dislikes, and breakdown by bot/pipeline + """ + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time) + + # Get total likes (feedback_type = 1) + likes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where( + persistence_monitoring.MonitoringFeedback.feedback_type == 1 + ) + if conditions: + likes_query = likes_query.where(sqlalchemy.and_(*conditions)) + likes_result = await self.ap.persistence_mgr.execute_async(likes_query) + total_likes = likes_result.scalar() or 0 + + # Get total dislikes (feedback_type = 2) + dislikes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where( + persistence_monitoring.MonitoringFeedback.feedback_type == 2 + ) + if conditions: + dislikes_query = dislikes_query.where(sqlalchemy.and_(*conditions)) + dislikes_result = await self.ap.persistence_mgr.execute_async(dislikes_query) + total_dislikes = dislikes_result.scalar() or 0 + + # Get total feedback count + total_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)) + if conditions: + total_query = total_query.where(sqlalchemy.and_(*conditions)) + total_result = await self.ap.persistence_mgr.execute_async(total_query) + total_feedback = total_result.scalar() or 0 + + # Calculate satisfaction rate + satisfaction_rate = (total_likes / total_feedback * 100) if total_feedback > 0 else 0 + + # Get feedback by bot + bot_stats_query = sqlalchemy.select( + persistence_monitoring.MonitoringFeedback.bot_id, + persistence_monitoring.MonitoringFeedback.bot_name, + sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id).label('total'), + sqlalchemy.func.sum( + sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 1, 1), else_=0) + ).label('likes'), + sqlalchemy.func.sum( + sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 2, 1), else_=0) + ).label('dislikes'), + ).group_by( + persistence_monitoring.MonitoringFeedback.bot_id, + persistence_monitoring.MonitoringFeedback.bot_name, + ) + if conditions: + bot_stats_query = bot_stats_query.where(sqlalchemy.and_(*conditions)) + bot_stats_result = await self.ap.persistence_mgr.execute_async(bot_stats_query) + bot_stats = [ + { + 'bot_id': row.bot_id, + 'bot_name': row.bot_name, + 'total': row.total, + 'likes': row.likes or 0, + 'dislikes': row.dislikes or 0, + } + for row in bot_stats_result.all() + ] + + return { + 'total_feedback': total_feedback, + 'total_likes': total_likes, + 'total_dislikes': total_dislikes, + 'satisfaction_rate': round(satisfaction_rate, 2), + 'by_bot': bot_stats, + } + + async def get_feedback_list( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + feedback_type: int | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[dict], int]: + """Get feedback list with filters.""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids)) + if feedback_type is not None: + conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type) + if start_time: + conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time) + + # Get total count + count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)) + if conditions: + count_query = count_query.where(sqlalchemy.and_(*conditions)) + count_result = await self.ap.persistence_mgr.execute_async(count_query) + total = count_result.scalar() or 0 + + # Get feedback list + query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by( + persistence_monitoring.MonitoringFeedback.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + query = query.limit(limit).offset(offset) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return ( + [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringFeedback, row[0] if isinstance(row, tuple) else row + ) + for row in rows + ], + total, + ) + + async def export_feedback( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export feedback as list of dictionaries for CSV conversion.""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time) + + query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by( + persistence_monitoring.MonitoringFeedback.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'id': row[0].id if isinstance(row, tuple) else row.id, + 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), + 'feedback_id': row[0].feedback_id if isinstance(row, tuple) else row.feedback_id, + 'feedback_type': 'like' + if (row[0].feedback_type if isinstance(row, tuple) else row.feedback_type) == 1 + else 'dislike', + 'feedback_content': row[0].feedback_content if isinstance(row, tuple) else row.feedback_content, + 'inaccurate_reasons': row[0].inaccurate_reasons if isinstance(row, tuple) else row.inaccurate_reasons, + 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, + 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, + 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, + 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id, + 'stream_id': row[0].stream_id if isinstance(row, tuple) else row.stream_id, + 'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id, + 'platform': row[0].platform if isinstance(row, tuple) else row.platform, + } + for row in rows + ] diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py new file mode 100644 index 0000000..2a6451c --- /dev/null +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import uuid +import json +import sqlalchemy + +from ....core import app +from ....entity.persistence import pipeline as persistence_pipeline + + +default_stage_order = [ + 'GroupRespondRuleCheckStage', # 群响应规则检查 + 'BanSessionCheckStage', # 封禁会话检查 + 'PreContentFilterStage', # 内容过滤前置阶段 + 'PreProcessor', # 预处理器 + 'ConversationMessageTruncator', # 会话消息截断器 + 'RequireRateLimitOccupancy', # 请求速率限制占用 + 'MessageProcessor', # 处理器 + 'ReleaseRateLimitOccupancy', # 释放速率限制占用 + 'PostContentFilterStage', # 内容过滤后置阶段 + 'ResponseWrapper', # 响应包装器 + 'LongTextProcessStage', # 长文本处理 + 'SendResponseBackStage', # 发送响应 +] + + +class PipelineService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_pipeline_metadata(self) -> list[dict]: + return [ + self.ap.pipeline_config_meta_trigger, + self.ap.pipeline_config_meta_safety, + self.ap.pipeline_config_meta_ai, + self.ap.pipeline_config_meta_output, + ] + + async def get_pipelines(self, sort_by: str = 'created_at', sort_order: str = 'DESC') -> list[dict]: + query = sqlalchemy.select(persistence_pipeline.LegacyPipeline) + + if sort_by == 'created_at': + if sort_order == 'DESC': + query = query.order_by(persistence_pipeline.LegacyPipeline.created_at.desc()) + else: + query = query.order_by(persistence_pipeline.LegacyPipeline.created_at.asc()) + elif sort_by == 'updated_at': + if sort_order == 'DESC': + query = query.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc()) + else: + query = query.order_by(persistence_pipeline.LegacyPipeline.updated_at.asc()) + + result = await self.ap.persistence_mgr.execute_async(query) + pipelines = result.all() + return [ + self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) + for pipeline in pipelines + ] + + async def get_pipeline(self, pipeline_uuid: str) -> dict | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid + ) + ) + + pipeline = result.first() + + if pipeline is None: + return None + + return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) + + async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str: + from ....utils import paths as path_utils + + # Check limitation + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + max_pipelines = limitation.get('max_pipelines', -1) + if max_pipelines >= 0: + existing_pipelines = await self.get_pipelines() + if len(existing_pipelines) >= max_pipelines: + raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached') + + pipeline_data['uuid'] = str(uuid.uuid4()) + pipeline_data['for_version'] = self.ap.ver_mgr.get_current_version() + pipeline_data['stages'] = default_stage_order.copy() + pipeline_data['is_default'] = default + + template_path = path_utils.get_resource_path('templates/default-pipeline-config.json') + with open(template_path, 'r', encoding='utf-8') as f: + pipeline_data['config'] = json.load(f) + + # Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default + if 'extensions_preferences' not in pipeline_data: + pipeline_data['extensions_preferences'] = { + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**pipeline_data) + ) + + pipeline = await self.get_pipeline(pipeline_data['uuid']) + + await self.ap.pipeline_mgr.load_pipeline(pipeline) + + return pipeline_data['uuid'] + + async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None: + pipeline_data = pipeline_data.copy() + for protected_field in ('uuid', 'for_version', 'stages', 'is_default'): + pipeline_data.pop(protected_field, None) + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_pipeline.LegacyPipeline) + .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid) + .values(**pipeline_data) + ) + + pipeline = await self.get_pipeline(pipeline_uuid) + + if 'name' in pipeline_data: + from ....entity.persistence import bot as persistence_bot + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid) + ) + + bots = result.all() + + for bot in bots: + bot_data = {'use_pipeline_name': pipeline_data['name']} + await self.ap.bot_service.update_bot(bot.uuid, bot_data) + + await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) + await self.ap.pipeline_mgr.load_pipeline(pipeline) + + # update all conversation that use this pipeline + for session in self.ap.sess_mgr.session_list: + if session.using_conversation is not None and session.using_conversation.pipeline_uuid == pipeline_uuid: + session.using_conversation = None + + async def delete_pipeline(self, pipeline_uuid: str) -> None: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid + ) + ) + await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) + + async def copy_pipeline(self, pipeline_uuid: str) -> str: + """Copy a pipeline with all its configurations""" + # Check limitation + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + max_pipelines = limitation.get('max_pipelines', -1) + if max_pipelines >= 0: + existing_pipelines = await self.get_pipelines() + if len(existing_pipelines) >= max_pipelines: + raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached') + + # Get the original pipeline + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid + ) + ) + + original_pipeline = result.first() + if original_pipeline is None: + raise ValueError(f'Pipeline {pipeline_uuid} not found') + + # Create new pipeline data + new_uuid = str(uuid.uuid4()) + new_pipeline_data = { + 'uuid': new_uuid, + 'name': f'{original_pipeline.name} (Copy)', + 'description': original_pipeline.description, + 'for_version': self.ap.ver_mgr.get_current_version(), + 'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(), + 'config': original_pipeline.config.copy() if original_pipeline.config else {}, + 'is_default': False, + 'extensions_preferences': ( + original_pipeline.extensions_preferences.copy() + if original_pipeline.extensions_preferences + else { + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, + } + ), + } + + # Insert the new pipeline + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**new_pipeline_data) + ) + + # Load the new pipeline + pipeline = await self.get_pipeline(new_uuid) + await self.ap.pipeline_mgr.load_pipeline(pipeline) + + return new_uuid + + async def update_pipeline_extensions( + self, + pipeline_uuid: str, + bound_plugins: list[dict], + bound_mcp_servers: list[str] = None, + enable_all_plugins: bool = True, + enable_all_mcp_servers: bool = True, + bound_skills: list[str] = None, + enable_all_skills: bool = True, + bound_mcp_resources: list[dict] = None, + mcp_resource_agent_read_enabled: bool | None = None, + ) -> None: + """Update the bound plugins and MCP servers for a pipeline""" + # Get current pipeline + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid + ) + ) + + pipeline = result.first() + if pipeline is None: + raise ValueError(f'Pipeline {pipeline_uuid} not found') + + # Update extensions_preferences + extensions_preferences = pipeline.extensions_preferences or {} + extensions_preferences['enable_all_plugins'] = enable_all_plugins + extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers + extensions_preferences['enable_all_skills'] = enable_all_skills + extensions_preferences['plugins'] = bound_plugins + if mcp_resource_agent_read_enabled is not None: + extensions_preferences['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled + if bound_mcp_servers is not None: + extensions_preferences['mcp_servers'] = bound_mcp_servers + if bound_skills is not None: + extensions_preferences['skills'] = bound_skills + if bound_mcp_resources is not None: + extensions_preferences['mcp_resources'] = bound_mcp_resources + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_pipeline.LegacyPipeline) + .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid) + .values(extensions_preferences=extensions_preferences) + ) + + # Reload pipeline to apply changes + await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) + pipeline = await self.get_pipeline(pipeline_uuid) + await self.ap.pipeline_mgr.load_pipeline(pipeline) diff --git a/src/langbot/pkg/api/http/service/provider.py b/src/langbot/pkg/api/http/service/provider.py new file mode 100644 index 0000000..598d72e --- /dev/null +++ b/src/langbot/pkg/api/http/service/provider.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import uuid +import traceback + +import sqlalchemy + +from ....core import app +from ....entity.persistence import model as persistence_model + + +class ModelProviderService: + """Service for managing model providers""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + @staticmethod + def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]: + if api_keys is None: + return [] + + raw_keys = [api_keys] if isinstance(api_keys, str) else list(api_keys) + normalized_keys = [] + seen_keys = set() + + for raw_key in raw_keys: + normalized_key = raw_key.strip() if isinstance(raw_key, str) else '' + if not normalized_key or normalized_key in seen_keys: + continue + normalized_keys.append(normalized_key) + seen_keys.add(normalized_key) + + return normalized_keys + + async def get_providers(self) -> list[dict]: + """Get all providers""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider)) + providers = result.all() + providers_list = [] + for p in providers: + provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, p) + # Parse api_keys if it's a JSON string + if isinstance(provider_dict.get('api_keys'), str): + import json + + try: + provider_dict['api_keys'] = json.loads(provider_dict['api_keys']) + except Exception: + provider_dict['api_keys'] = [] + providers_list.append(provider_dict) + return providers_list + + async def get_provider(self, provider_uuid: str) -> dict | None: + """Get a single provider by UUID""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.uuid == provider_uuid + ) + ) + provider = result.first() + if provider is None: + return None + provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider) + # Parse api_keys if it's a JSON string + if isinstance(provider_dict.get('api_keys'), str): + import json + + try: + provider_dict['api_keys'] = json.loads(provider_dict['api_keys']) + except Exception: + provider_dict['api_keys'] = [] + return provider_dict + + async def create_provider(self, provider_data: dict) -> str: + """Create a new provider""" + provider_data['uuid'] = str(uuid.uuid4()) + provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys')) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data) + ) + + # load to runtime + runtime_provider = await self.ap.model_mgr.load_provider(provider_data) + self.ap.model_mgr.provider_dict[runtime_provider.provider_entity.uuid] = runtime_provider + return provider_data['uuid'] + + async def update_provider(self, provider_uuid: str, provider_data: dict) -> None: + """Update an existing provider""" + if 'uuid' in provider_data: + del provider_data['uuid'] + if 'api_keys' in provider_data: + provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys')) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_model.ModelProvider) + .where(persistence_model.ModelProvider.uuid == provider_uuid) + .values(**provider_data) + ) + await self.ap.model_mgr.reload_provider(provider_uuid) + + async def delete_provider(self, provider_uuid: str) -> None: + """Delete a provider (only if no models reference it)""" + # Check if any models use this provider + llm_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.LLMModel).where( + persistence_model.LLMModel.provider_uuid == provider_uuid + ) + ) + if llm_result.first() is not None: + raise ValueError('Cannot delete provider: LLM models still reference it') + + embedding_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.EmbeddingModel).where( + persistence_model.EmbeddingModel.provider_uuid == provider_uuid + ) + ) + if embedding_result.first() is not None: + raise ValueError('Cannot delete provider: Embedding models still reference it') + + rerank_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.RerankModel).where( + persistence_model.RerankModel.provider_uuid == provider_uuid + ) + ) + if rerank_result.first() is not None: + raise ValueError('Cannot delete provider: Rerank models still reference it') + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.uuid == provider_uuid + ) + ) + + await self.ap.model_mgr.remove_provider(provider_uuid) + + async def get_provider_model_counts(self, provider_uuid: str) -> dict: + """Get count of models using this provider""" + llm_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(persistence_model.LLMModel) + .where(persistence_model.LLMModel.provider_uuid == provider_uuid) + ) + llm_count = llm_result.scalar() or 0 + + embedding_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(persistence_model.EmbeddingModel) + .where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid) + ) + embedding_count = embedding_result.scalar() or 0 + + rerank_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.count()) + .select_from(persistence_model.RerankModel) + .where(persistence_model.RerankModel.provider_uuid == provider_uuid) + ) + rerank_count = rerank_result.scalar() or 0 + + return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count} + + async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str: + """Find existing provider or create new one""" + api_keys = self._normalize_api_keys(api_keys) + + # Try to find existing provider with same config + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.requester == requester, + persistence_model.ModelProvider.base_url == base_url, + ) + ) + for provider in result.all(): + if sorted(provider.api_keys or []) == sorted(api_keys or []): + return provider.uuid + + # Create new provider + provider_name = requester + if base_url: + try: + from urllib.parse import urlparse + + parsed = urlparse(base_url) + provider_name = parsed.netloc or requester + except Exception: + pass + + return await self.create_provider( + { + 'name': provider_name, + 'requester': requester, + 'base_url': base_url, + 'api_keys': api_keys, + } + ) + + async def update_space_model_provider_api_keys(self, api_key: str) -> None: + """Update Space model provider API keys""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_model.ModelProvider) + .where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000') + .values(api_keys=self._normalize_api_keys(api_key)) + ) + await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000') + + async def scan_provider_models(self, provider_uuid: str, model_type: str | None = None) -> dict: + provider = await self.get_provider(provider_uuid) + if provider is None: + raise ValueError('provider not found') + + runtime_provider = await self.ap.model_mgr.load_provider(provider) + + try: + scan_result = await runtime_provider.requester.scan_models( + runtime_provider.token_mgr.get_token() if runtime_provider.token_mgr.tokens else None + ) + except NotImplementedError: + raise ValueError('current provider does not support model scanning') + except Exception as exc: + self.ap.logger.warning( + f'Failed to scan models for provider {provider_uuid}: {exc}\n{traceback.format_exc()}' + ) + raise ValueError(str(exc)) from exc + + if isinstance(scan_result, dict): + scanned_models = scan_result.get('models', []) + debug_info = scan_result.get('debug') + else: + scanned_models = scan_result + debug_info = None + + llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid) + embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid) + existing_llm_names = {model['name'] for model in llm_models} + existing_embedding_names = {model['name'] for model in embedding_models} + + filtered_models = [] + for model in scanned_models: + scanned_type = model.get('type', 'llm') + if model_type and scanned_type != model_type: + continue + + model_name = model.get('name') or model.get('id') + if not model_name: + continue + + filtered_models.append( + { + 'id': model.get('id', model_name), + 'name': model_name, + 'type': scanned_type, + 'abilities': model.get('abilities', []), + 'display_name': model.get('display_name'), + 'description': model.get('description'), + 'context_length': model.get('context_length'), + 'owned_by': model.get('owned_by'), + 'input_modalities': model.get('input_modalities', []), + 'output_modalities': model.get('output_modalities', []), + 'already_added': ( + model_name in existing_embedding_names + if scanned_type == 'embedding' + else model_name in existing_llm_names + ), + } + ) + + return {'models': filtered_models, 'debug': debug_info} diff --git a/src/langbot/pkg/api/http/service/skill.py b/src/langbot/pkg/api/http/service/skill.py new file mode 100644 index 0000000..94b9269 --- /dev/null +++ b/src/langbot/pkg/api/http/service/skill.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import io +import inspect +import os +import posixpath +import zipfile +from typing import Optional +from urllib.parse import quote, unquote, urlparse + +import httpx + +from ....core import app +from ....skill.utils import parse_frontmatter + + +_PUBLIC_SKILL_FIELDS = ( + 'name', + 'display_name', + 'description', + 'instructions', + 'package_root', + 'created_at', + 'updated_at', +) + +_GITHUB_ASSET_HOSTS = { + 'github.com', + 'api.github.com', + 'objects.githubusercontent.com', + 'githubusercontent.com', + 'raw.githubusercontent.com', + 'codeload.github.com', +} + + +class SkillService: + """Filesystem-backed skill management service.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + def _box_service(self): + box_service = getattr(self.ap, 'box_service', None) + if box_service is not None and getattr(box_service, 'available', False): + return box_service + return None + + def _require_box(self, action: str): + """Return the Box service or raise if it is not available. + + Box is the only source of truth for skills. Every read and write + operation goes through it — there is no local-filesystem fallback. + """ + box_service = self._box_service() + if box_service is not None: + return box_service + ap_box = getattr(self.ap, 'box_service', None) + if ap_box is None: + reason = 'not initialised' + elif not getattr(ap_box, 'enabled', True): + reason = 'disabled in config (box.enabled = false)' + else: + connector_error = getattr(ap_box, '_connector_error', '') or 'currently unavailable' + reason = f'unavailable: {connector_error}' + raise ValueError( + f'{action} requires the Box runtime, which is {reason}. ' + f'Enable Box in config.yaml (box.enabled = true) and ensure the ' + f'runtime is reachable before retrying.' + ) + + def _require_box_for_write(self, action: str) -> None: + """Backwards-compatible alias preserved for clarity at call sites.""" + self._require_box(action) + + @staticmethod + def _serialize_skill(skill: dict) -> dict: + return {field: skill.get(field) for field in _PUBLIC_SKILL_FIELDS if field in skill} + + async def list_skills(self) -> list[dict]: + # When Box is unavailable, surface an empty list rather than raising — + # the skills page should render cleanly, and the UI separately renders + # a "Box disabled / unavailable" banner via useBoxStatus. + box_service = self._box_service() + if box_service is None: + return [] + return [self._serialize_skill(skill) for skill in await box_service.list_skills()] + + async def get_skill(self, skill_name: str) -> Optional[dict]: + box_service = self._box_service() + if box_service is None: + return None + skill = await box_service.get_skill(skill_name) + return self._serialize_skill(skill) if skill else None + + async def get_skill_by_name(self, name: str) -> Optional[dict]: + return await self.get_skill(name) + + async def create_skill(self, data: dict) -> dict: + box_service = self._require_box('Creating a skill') + created = await box_service.create_skill(data) + await self._reload_skills() + return self._serialize_skill(created) + + async def update_skill(self, skill_name: str, data: dict) -> dict: + box_service = self._require_box('Editing a skill') + updated = await box_service.update_skill(skill_name, data) + await self._reload_skills() + return self._serialize_skill(updated) + + async def delete_skill(self, skill_name: str) -> bool: + box_service = self._require_box('Deleting a skill') + await box_service.delete_skill(skill_name) + await self._reload_skills() + return True + + async def list_skill_files( + self, + skill_name: str, + path: str = '.', + include_hidden: bool = False, + max_entries: int = 200, + ) -> dict: + box_service = self._require_box('Browsing skill files') + return await box_service.list_skill_files(skill_name, path, include_hidden, max_entries) + + async def read_skill_file(self, skill_name: str, path: str) -> dict: + box_service = self._require_box('Reading a skill file') + return await box_service.read_skill_file(skill_name, path) + + async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict: + box_service = self._require_box('Editing skill files') + result = await box_service.write_skill_file(skill_name, path, content) + await self._reload_skills() + return result + + async def install_from_github(self, data: dict) -> list[dict]: + box_service = self._require_box('Installing a skill from GitHub') + owner = str(data['owner']).strip() + repo = str(data['repo']).strip() + release_tag = str(data.get('release_tag', '')).strip() + raw_asset_url = str(data['asset_url']).strip() + if self._is_github_skill_md_url(raw_asset_url): + return await self._install_github_skill_md(raw_asset_url, owner=owner, repo=repo, data=data) + + asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag) + source_subdir = str(data.get('source_subdir', '') or '').strip() + + zip_bytes = await self._download_github_asset(asset_url) + filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip' + installed = await box_service.install_skill_zip( + zip_bytes, + filename, + source_paths=data.get('source_paths') or [], + source_path=str(data.get('source_path', '') or ''), + source_subdir=source_subdir, + ) + await self._reload_skills() + return [self._serialize_skill(skill) for skill in installed] + + async def preview_install_from_github(self, data: dict) -> list[dict]: + box_service = self._require_box('Previewing a skill from GitHub') + owner = str(data['owner']).strip() + repo = str(data['repo']).strip() + release_tag = str(data.get('release_tag', '')).strip() + raw_asset_url = str(data['asset_url']).strip() + if self._is_github_skill_md_url(raw_asset_url): + return await self._preview_github_skill_md(raw_asset_url, owner=owner, repo=repo) + + asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag) + source_subdir = str(data.get('source_subdir', '') or '').strip() + + zip_bytes = await self._download_github_asset(asset_url) + return await box_service.preview_skill_zip( + zip_bytes, + f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip', + source_subdir=source_subdir, + ) + + async def install_from_zip_upload( + self, + *, + file_bytes: bytes, + filename: str, + source_paths: list[str] | None = None, + source_path: str = '', + ) -> list[dict]: + box_service = self._require_box('Installing a skill from upload') + installed = await box_service.install_skill_zip( + file_bytes, + filename, + source_paths=source_paths or [], + source_path=source_path, + ) + await self._reload_skills() + return [self._serialize_skill(skill) for skill in installed] + + async def preview_install_from_zip_upload(self, *, file_bytes: bytes, filename: str) -> list[dict]: + box_service = self._require_box('Previewing a skill upload') + return await box_service.preview_skill_zip(file_bytes, filename) + + async def _install_github_skill_md(self, asset_url: str, *, owner: str, repo: str, data: dict) -> list[dict]: + box_service = self._require_box('Installing a skill from GitHub') + zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip( + asset_url, + owner=owner, + repo=repo, + ) + + installed = await box_service.install_skill_zip( + zip_bytes, + filename, + source_paths=data.get('source_paths') or [], + source_path=str(data.get('source_path', '') or ''), + target_suffix='', + ) + await self._reload_skills() + return [self._serialize_skill(skill) for skill in installed] + + async def _preview_github_skill_md(self, asset_url: str, *, owner: str, repo: str) -> list[dict]: + box_service = self._require_box('Previewing a skill from GitHub') + zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip( + asset_url, + owner=owner, + repo=repo, + ) + return await box_service.preview_skill_zip(zip_bytes, f'{package_name}.zip', target_suffix='') + + async def reload_skills(self) -> list[dict]: + await self._reload_skills() + return await self.list_skills() + + async def scan_directory_async(self, path: str) -> dict: + box_service = self._require_box('Scanning a skill directory') + return await box_service.scan_skill_directory(path) + + async def _reload_skills(self) -> None: + skill_mgr = getattr(self.ap, 'skill_mgr', None) + reload_skills = getattr(skill_mgr, 'reload_skills', None) + if not callable(reload_skills): + return + result = reload_skills() + if inspect.isawaitable(result): + await result + + async def _download_github_asset(self, asset_url: str) -> bytes: + async with httpx.AsyncClient(follow_redirects=True, timeout=120) as client: + resp = await client.get(asset_url) + resp.raise_for_status() + return resp.content + + async def _download_github_skill_directory_as_zip( + self, asset_url: str, *, owner: str, repo: str + ) -> tuple[bytes, str, str]: + info = self._parse_github_skill_md_url(asset_url, owner=owner, repo=repo) + archive_url = f'https://codeload.github.com/{owner}/{repo}/zip/{quote(info["ref"], safe="/")}' + archive_bytes = await self._download_github_asset(archive_url) + + try: + source_archive = zipfile.ZipFile(io.BytesIO(archive_bytes), 'r') + except zipfile.BadZipFile as exc: + raise ValueError('GitHub repository archive must be a valid .zip archive') from exc + + with source_archive as source_zip: + skill_entry = self._find_github_skill_archive_entry(source_zip, info['file_path']) + try: + skill_md_content = source_zip.read(skill_entry).decode('utf-8') + except UnicodeDecodeError as exc: + raise ValueError('GitHub SKILL.md must be valid UTF-8 text') from exc + + package_name = self._resolve_github_skill_md_package_name(skill_md_content, info['package_name']) + source_skill_dir = posixpath.dirname(posixpath.normpath(skill_entry.filename)) + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as target_zip: + self._copy_github_skill_directory_to_zip(source_zip, target_zip, source_skill_dir, package_name) + return buffer.getvalue(), f'{package_name}.zip', package_name + + def _find_github_skill_archive_entry(self, archive: zipfile.ZipFile, file_path: str) -> zipfile.ZipInfo: + normalized_file_path = posixpath.normpath(file_path).lower() + for member in archive.infolist(): + if member.is_dir(): + continue + normalized_member = posixpath.normpath(member.filename) + path_parts = normalized_member.split('/', 1) + if len(path_parts) != 2: + continue + archive_relative_path = path_parts[1].lower() + if archive_relative_path == normalized_file_path: + return member + raise ValueError(f'GitHub archive does not contain requested SKILL.md: {file_path}') + + def _copy_github_skill_directory_to_zip( + self, + source_zip: zipfile.ZipFile, + target_zip: zipfile.ZipFile, + source_skill_dir: str, + package_name: str, + ) -> None: + normalized_source_dir = posixpath.normpath(source_skill_dir) + source_prefix = f'{normalized_source_dir}/' + copied_files = 0 + + for member in source_zip.infolist(): + normalized_member = posixpath.normpath(member.filename) + if normalized_member != normalized_source_dir and not normalized_member.startswith(source_prefix): + continue + + relative_path = posixpath.relpath(normalized_member, normalized_source_dir) + if relative_path in ('', '.'): + continue + if relative_path.startswith('../') or relative_path == '..' or posixpath.isabs(relative_path): + raise ValueError(f'GitHub archive contains an unsafe skill path: {member.filename}') + + target_name = f'{package_name}/{relative_path}' + if member.is_dir() and not target_name.endswith('/'): + target_name = f'{target_name}/' + target_info = zipfile.ZipInfo(target_name, date_time=member.date_time) + target_info.external_attr = member.external_attr + target_info.compress_type = zipfile.ZIP_DEFLATED + + if member.is_dir(): + target_zip.writestr(target_info, b'') + continue + + target_zip.writestr(target_info, source_zip.read(member)) + copied_files += 1 + + if copied_files == 0: + raise ValueError('GitHub skill directory is empty') + + def _uploaded_skill_target_stem(self, filename: str) -> str: + stem = os.path.splitext(os.path.basename(str(filename or '').strip()))[0] + safe_stem = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '-' for ch in stem).strip('-_') + if not safe_stem: + safe_stem = 'uploaded-skill' + return safe_stem + + @staticmethod + def _is_github_skill_md_url(asset_url: str) -> bool: + parsed = urlparse(str(asset_url or '').strip()) + normalized_path = posixpath.normpath(parsed.path or '/') + return normalized_path.lower().endswith('/skill.md') + + def _parse_github_skill_md_url(self, asset_url: str, *, owner: str, repo: str) -> dict: + parsed = urlparse(str(asset_url or '').strip()) + if parsed.scheme != 'https' or not parsed.netloc: + raise ValueError('asset_url must be a valid HTTPS GitHub SKILL.md URL') + + host = parsed.netloc.lower() + path_parts = [unquote(part) for part in (parsed.path or '').split('/') if part] + if host == 'github.com': + if ( + len(path_parts) < 5 + or path_parts[0] != owner + or path_parts[1] != repo + or path_parts[2] + not in ( + 'blob', + 'raw', + ) + ): + raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo blob path') + ref = path_parts[3] + file_path = '/'.join(path_parts[4:]) + elif host == 'raw.githubusercontent.com': + if len(path_parts) < 4 or path_parts[0] != owner or path_parts[1] != repo: + raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo raw path') + ref = path_parts[2] + file_path = '/'.join(path_parts[3:]) + else: + raise ValueError('asset_url must point to a GitHub SKILL.md file') + + normalized_file_path = posixpath.normpath(file_path) + normalized_file_path_lower = normalized_file_path.lower() + if normalized_file_path_lower != 'skill.md' and not normalized_file_path_lower.endswith('/skill.md'): + raise ValueError('GitHub skill import requires a URL ending with SKILL.md') + + parent_dir = posixpath.basename(posixpath.dirname(normalized_file_path)) or repo + return { + 'ref': ref, + 'file_path': normalized_file_path, + 'package_name': self._uploaded_skill_target_stem(parent_dir), + } + + def _resolve_github_skill_md_package_name(self, content: str, fallback: str) -> str: + metadata, _instructions = parse_frontmatter(content) + candidate = str(metadata.get('name') or fallback or '').strip() + try: + return self._validate_skill_name(candidate) + except ValueError: + return self._validate_skill_name(fallback) + + @staticmethod + def _validate_github_asset_url(asset_url: str, *, owner: str, repo: str, release_tag: str) -> str: + parsed = urlparse(str(asset_url).strip()) + if parsed.scheme != 'https' or not parsed.netloc: + raise ValueError('asset_url must be a valid HTTPS GitHub asset URL') + + host = parsed.netloc.lower() + if host not in _GITHUB_ASSET_HOSTS: + raise ValueError('asset_url must point to a GitHub-hosted release asset or archive') + + normalized_path = posixpath.normpath(parsed.path or '/') + allowed_prefixes = [ + f'/repos/{owner}/{repo}/', + f'/{owner}/{repo}/', + ] + if not any(normalized_path.startswith(prefix) for prefix in allowed_prefixes): + raise ValueError('asset_url does not match the requested owner/repo') + + if release_tag and release_tag not in parsed.path and release_tag not in parsed.query: + raise ValueError('asset_url does not match the requested release_tag') + + return parsed.geturl() + + @staticmethod + def _validate_skill_name(name: str) -> str: + name = str(name or '').strip() + if not name: + raise ValueError('Skill name is required') + if not name.replace('-', '').replace('_', '').isalnum(): + raise ValueError('Skill name can only contain letters, numbers, hyphens and underscores') + if len(name) > 64: + raise ValueError('Skill name cannot exceed 64 characters') + return name diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py new file mode 100644 index 0000000..6de2593 --- /dev/null +++ b/src/langbot/pkg/api/http/service/space.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from langbot.pkg.utils import httpclient +import typing +import datetime +import time +import sqlalchemy + +from ....core import app +from ....entity.persistence import user +from ....entity.dto.space_model import SpaceModel + + +class SpaceService: + """Service for interacting with LangBot Space API""" + + ap: app.Application + _credits_cache: typing.Dict[str, typing.Tuple[int, float]] # {user_email: (credits, timestamp)} + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + self._credits_cache = {} + + def _get_space_config(self) -> typing.Dict[str, str]: + """Get Space configuration from config file""" + space_config = self.ap.instance_config.data.get('space', {}) + return { + 'url': space_config.get('url', 'https://space.langbot.app'), + 'oauth_authorize_url': space_config.get('oauth_authorize_url', 'https://space.langbot.app/auth/authorize'), + } + + async def _get_user_by_email(self, user_email: str) -> user.User | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(user.User).where(user.User.user == user_email) + ) + result_list = result.all() + return result_list[0] if result_list else None + + async def _ensure_valid_token(self, user_email: str) -> str | None: + """Ensure access token is valid, refresh if expired. Returns valid access_token or None.""" + user_obj = await self._get_user_by_email(user_email) + if not user_obj or user_obj.account_type != 'space': + return None + + if not user_obj.space_access_token: + return None + + # Check if token is expired (with 60s buffer) + if user_obj.space_access_token_expires_at: + if datetime.datetime.now() >= user_obj.space_access_token_expires_at - datetime.timedelta(seconds=60): + # Token expired, try to refresh + if user_obj.space_refresh_token: + try: + new_token = await self._refresh_and_save_token(user_obj) + return new_token + except Exception: + return None + return None + + return user_obj.space_access_token + + async def _refresh_and_save_token(self, user_obj: user.User) -> str: + """Refresh token and save to database""" + token_data = await self.refresh_token(user_obj.space_refresh_token) + access_token = token_data.get('access_token') + expires_in = token_data.get('expires_in', 0) + + if not access_token: + raise ValueError('Failed to refresh token') + + expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(user.User) + .where(user.User.user == user_obj.user) + .values( + space_access_token=access_token, + space_access_token_expires_at=expires_at, + ) + ) + + return access_token + + # === Raw API calls (no token validation) === + + def get_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str: + """Get the Space OAuth authorization URL for redirect""" + space_config = self._get_space_config() + authorize_url = space_config['oauth_authorize_url'] + params = f'redirect_uri={redirect_uri}' + if state: + params += f'&state={state}' + return f'{authorize_url}?{params}' + + async def exchange_oauth_code(self, code: str) -> typing.Dict: + """Exchange OAuth authorization code for tokens""" + from langbot.pkg.utils import constants + + space_config = self._get_space_config() + space_url = space_config['url'] + + session = httpclient.get_session() + async with session.post( + f'{space_url}/api/v1/accounts/oauth/token', + json={'code': code, 'instance_id': constants.instance_id}, + ) as response: + if response.status != 200: + raise ValueError(f'Failed to exchange OAuth code: {await response.text()}') + data = await response.json() + if data.get('code') != 0: + raise ValueError(f'Failed to exchange OAuth code: {data.get("msg")}') + return data.get('data', {}) + + async def refresh_token(self, refresh_token: str) -> typing.Dict: + """Refresh Space access token""" + space_config = self._get_space_config() + space_url = space_config['url'] + + session = httpclient.get_session() + async with session.post( + f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token} + ) as response: + if response.status != 200: + raise ValueError(f'Failed to refresh token: {await response.text()}') + data = await response.json() + if data.get('code') != 0: + raise ValueError(f'Failed to refresh token: {data.get("msg")}') + return data.get('data', {}) + + async def get_user_info_raw(self, access_token: str) -> typing.Dict: + """Get user info from Space using access token (no validation)""" + space_config = self._get_space_config() + space_url = space_config['url'] + + session = httpclient.get_session() + async with session.get( + f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'} + ) as response: + if response.status != 200: + raise ValueError(f'Failed to get user info: {await response.text()}') + data = await response.json() + if data.get('code') != 0: + raise ValueError(f'Failed to get user info: {data.get("msg")}') + return data.get('data', {}) + + # === API calls with token validation === + + async def get_user_info(self, user_email: str) -> typing.Dict | None: + """Get user info from Space (with token validation)""" + access_token = await self._ensure_valid_token(user_email) + if not access_token: + return None + return await self.get_user_info_raw(access_token) + + async def get_credits(self, user_email: str, force_refresh: bool = False) -> int | None: + """Get Space credits for user with caching (60s TTL)""" + cache_ttl = 60 + + if not force_refresh and user_email in self._credits_cache: + credits, ts = self._credits_cache[user_email] + if time.time() - ts < cache_ttl: + return credits + + try: + info = await self.get_user_info(user_email) + if info is None: + return None + credits = info.get('credits') + if credits is not None: + self._credits_cache[user_email] = (credits, time.time()) + return credits + except Exception: + return self._credits_cache.get(user_email, (None, 0))[0] + + async def get_models(self) -> typing.List[SpaceModel]: + """Get models from Space""" + + space_config = self._get_space_config() + space_url = space_config['url'] + + session = httpclient.get_session() + async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response: + if response.status != 200: + raise ValueError(f'Failed to get models: {await response.text()}') + data = await response.json() + if data.get('code') != 0: + raise ValueError(f'Failed to get models: {data.get("msg")}') + models_data = data.get('data', {}).get('models', []) + return [SpaceModel.model_validate(model_dict) for model_dict in models_data] diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py new file mode 100644 index 0000000..a9185f9 --- /dev/null +++ b/src/langbot/pkg/api/http/service/user.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import sqlalchemy +import argon2 +import jwt +import datetime +import typing +import asyncio + +from ....core import app +from ....entity.persistence import user +from ....utils import constants +from ....entity.errors import account as account_errors + + +class UserService: + ap: app.Application + _create_user_lock: asyncio.Lock + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + self._create_user_lock = asyncio.Lock() + self._password_hash_lock = asyncio.Semaphore(1) + + async def _hash_password(self, password: str) -> str: + async with self._password_hash_lock: + return await asyncio.to_thread(argon2.PasswordHasher().hash, password) + + async def _verify_password(self, hashed_password: str, password: str) -> None: + async with self._password_hash_lock: + await asyncio.to_thread(argon2.PasswordHasher().verify, hashed_password, password) + + async def is_initialized(self) -> bool: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1)) + + result_list = result.all() + return result_list is not None and len(result_list) > 0 + + async def create_user(self, user_email: str, password: str) -> None: + hashed_password = await self._hash_password(password) + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local') + ) + + async def get_user_by_email(self, user_email: str) -> user.User | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(user.User).where(user.User.user == user_email) + ) + + result_list = result.all() + return result_list[0] if result_list is not None and len(result_list) > 0 else None + + async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None: + """Get user by Space account UUID""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid) + ) + + result_list = result.all() + return result_list[0] if result_list is not None and len(result_list) > 0 else None + + async def authenticate(self, user_email: str, password: str) -> str | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(user.User).where(user.User.user == user_email) + ) + + result_list = result.all() + + if result_list is None or len(result_list) == 0: + raise ValueError('用户不存在') + + user_obj = result_list[0] + + # Check if this user has a local password set + if not user_obj.password: + raise ValueError('请使用 Space 账户登录') + + await self._verify_password(user_obj.password, password) + + return await self.generate_jwt_token(user_email) + + async def generate_jwt_token(self, user_email: str) -> str: + jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] + jwt_expire = self.ap.instance_config.data['system']['jwt']['expire'] + + payload = { + 'user': user_email, + 'iss': 'LangBot-' + constants.edition, + 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=jwt_expire), + } + + return jwt.encode(payload, jwt_secret, algorithm='HS256') + + async def verify_jwt_token(self, token: str) -> str: + jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] + + return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user'] + + async def reset_password(self, user_email: str, new_password: str) -> None: + hashed_password = await self._hash_password(new_password) + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) + ) + + async def change_password(self, user_email: str, current_password: str, new_password: str) -> None: + user_obj = await self.get_user_by_email(user_email) + if user_obj is None: + raise ValueError('User not found') + + if not user_obj.password: + raise ValueError('No local password set, please set a password first') + + await self._verify_password(user_obj.password, current_password) + + hashed_password = await self._hash_password(new_password) + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) + ) + + # Space user management + + async def create_or_update_space_user( + self, + space_account_uuid: str, + email: str, + access_token: str, + refresh_token: str, + api_key: str, + expires_in: int = 0, + ) -> user.User: + """Create or update a Space user account (only if system not initialized or user exists)""" + expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None + + async with self._create_user_lock: + # Check if user with this Space UUID already exists + existing_user = await self.get_user_by_space_account_uuid(space_account_uuid) + + if existing_user: + # Update existing user's tokens + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(user.User) + .where(user.User.space_account_uuid == space_account_uuid) + .values( + space_access_token=access_token, + space_refresh_token=refresh_token, + space_api_key=api_key, + space_access_token_expires_at=expires_at, + ) + ) + await self.ap.provider_service.update_space_model_provider_api_keys(api_key) + return await self.get_user_by_space_account_uuid(space_account_uuid) + + # Check if user with same email exists + existing_email_user = await self.get_user_by_email(email) + if existing_email_user: + # Update existing user to link with Space account + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(user.User) + .where(user.User.user == email) + .values( + account_type='space', + space_account_uuid=space_account_uuid, + space_access_token=access_token, + space_refresh_token=refresh_token, + space_api_key=api_key, + space_access_token_expires_at=expires_at, + ) + ) + await self.ap.provider_service.update_space_model_provider_api_keys(api_key) + return await self.get_user_by_email(email) + + # Check if system is already initialized + is_initialized = await self.is_initialized() + if is_initialized: + raise account_errors.AccountEmailMismatchError() + + # Create new Space user (first time initialization) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(user.User).values( + user=email, + password='', # Space users don't have local password + account_type='space', + space_account_uuid=space_account_uuid, + space_access_token=access_token, + space_refresh_token=refresh_token, + space_api_key=api_key, + space_access_token_expires_at=expires_at, + ) + ) + await self.ap.provider_service.update_space_model_provider_api_keys(api_key) + + return await self.get_user_by_space_account_uuid(space_account_uuid) + + async def authenticate_space_user( + self, access_token: str, refresh_token: str, expires_in: int = 0 + ) -> typing.Tuple[str, user.User]: + """Authenticate with Space and return JWT token""" + # Get user info from Space using raw API (token just obtained, no need to validate) + user_info = await self.ap.space_service.get_user_info_raw(access_token) + + account = user_info.get('account', {}) + api_key = user_info.get('api_key', '') + + space_account_uuid = account.get('uuid') + email = account.get('email') + + if not space_account_uuid or not email: + raise ValueError('Invalid Space user info') + + # Create or update Space user in local database + user_obj = await self.create_or_update_space_user( + space_account_uuid=space_account_uuid, + email=email, + access_token=access_token, + refresh_token=refresh_token, + api_key=api_key, + expires_in=expires_in, + ) + + # Generate JWT token + jwt_token = await self.generate_jwt_token(email) + + return jwt_token, user_obj + + async def get_first_user(self) -> user.User | None: + """Get the first user (for single-user mode)""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1)) + result_list = result.all() + return result_list[0] if result_list else None + + async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None: + """Set or change password for a user""" + user_obj = await self.get_user_by_email(user_email) + + if user_obj is None: + raise ValueError('User not found') + + # If user already has a password, verify current password + has_password = bool(user_obj.password and user_obj.password.strip()) + if has_password: + if not current_password: + raise ValueError('Current password is required') + await self._verify_password(user_obj.password, current_password) + + hashed_password = await self._hash_password(new_password) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) + ) + + async def bind_space_account(self, user_email: str, code: str) -> user.User: + """Bind Space account to existing local account""" + # Exchange code for tokens + token_data = await self.ap.space_service.exchange_oauth_code(code) + access_token = token_data.get('access_token') + refresh_token = token_data.get('refresh_token') + expires_in = token_data.get('expires_in', 0) + + if not access_token: + raise ValueError('Failed to get access token from Space') + + expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None + + # Get Space user info (token just obtained, use raw API) + user_info = await self.ap.space_service.get_user_info_raw(access_token) + account = user_info.get('account', {}) + api_key = user_info.get('api_key', '') + + space_account_uuid = account.get('uuid') + space_email = account.get('email') + + if not space_account_uuid or not space_email: + raise ValueError('Invalid Space user info') + + # Check if this Space account is already bound to another user + existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid) + if existing_space_user and existing_space_user.user != user_email: + raise ValueError('This Space account is already bound to another user') + + # Update local account to Space account + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(user.User) + .where(user.User.user == user_email) + .values( + user=space_email, # Update email to Space email + account_type='space', + space_account_uuid=space_account_uuid, + space_access_token=access_token, + space_refresh_token=refresh_token, + space_api_key=api_key, + space_access_token_expires_at=expires_at, + ) + ) + + # Update Space model provider API keys + await self.ap.provider_service.update_space_model_provider_api_keys(api_key) + + return await self.get_user_by_email(space_email) diff --git a/src/langbot/pkg/api/http/service/webhook.py b/src/langbot/pkg/api/http/service/webhook.py new file mode 100644 index 0000000..b3a6711 --- /dev/null +++ b/src/langbot/pkg/api/http/service/webhook.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import sqlalchemy + +from ....core import app +from ....entity.persistence import webhook + + +class WebhookService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_webhooks(self) -> list[dict]: + """Get all webhooks""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(webhook.Webhook)) + + webhooks = result.all() + return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks] + + async def create_webhook(self, name: str, url: str, description: str = '', enabled: bool = True) -> dict: + """Create a new webhook""" + webhook_data = {'name': name, 'url': url, 'description': description, 'enabled': enabled} + + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(webhook.Webhook).values(**webhook_data)) + + # Retrieve the created webhook + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.url == url).order_by(webhook.Webhook.id.desc()) + ) + created_webhook = result.first() + + return self.ap.persistence_mgr.serialize_model(webhook.Webhook, created_webhook) + + async def get_webhook(self, webhook_id: int) -> dict | None: + """Get a specific webhook by ID""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id) + ) + + wh = result.first() + + if wh is None: + return None + + return self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) + + async def update_webhook( + self, webhook_id: int, name: str = None, url: str = None, description: str = None, enabled: bool = None + ) -> None: + """Update a webhook's metadata""" + update_data = {} + if name is not None: + update_data['name'] = name + if url is not None: + update_data['url'] = url + if description is not None: + update_data['description'] = description + if enabled is not None: + update_data['enabled'] = enabled + + if update_data: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data) + ) + + async def delete_webhook(self, webhook_id: int) -> None: + """Delete a webhook""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id) + ) + + async def get_enabled_webhooks(self) -> list[dict]: + """Get all enabled webhooks""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True) + ) + + webhooks = result.all() + return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks] diff --git a/src/langbot/pkg/api/mcp/__init__.py b/src/langbot/pkg/api/mcp/__init__.py new file mode 100644 index 0000000..dac07bf --- /dev/null +++ b/src/langbot/pkg/api/mcp/__init__.py @@ -0,0 +1,14 @@ +"""LangBot MCP (Model Context Protocol) server. + +This package exposes a subset of LangBot's HTTP service API as MCP tools so +that external AI agents can manage a LangBot instance through the MCP +protocol. The MCP server reuses the same API-key authentication as the HTTP +API (including the global API key from ``config.yaml``). + +See ``server.py`` for the tool surface and ``mount.py`` for the ASGI +integration with the Quart HTTP app. +""" + +from .server import LangBotMCPServer + +__all__ = ['LangBotMCPServer'] diff --git a/src/langbot/pkg/api/mcp/mount.py b/src/langbot/pkg/api/mcp/mount.py new file mode 100644 index 0000000..d113b25 --- /dev/null +++ b/src/langbot/pkg/api/mcp/mount.py @@ -0,0 +1,112 @@ +"""ASGI integration: serve the LangBot MCP server alongside the Quart HTTP app. + +The Quart app and the MCP server are both ASGI apps. We front them with a small +dispatcher ASGI callable: + +- Requests whose path is (or is under) ``/mcp`` are authenticated with a + LangBot API key (reusing ``apikey_service.verify_api_key``, which also + accepts the global API key from ``config.yaml``) and then handed to the + FastMCP Starlette app. +- Every other request goes to the Quart app unchanged. + +The FastMCP streamable-HTTP transport requires its session manager's lifespan +to be running. Rather than rely on the dispatcher receiving ASGI lifespan +events (Quart owns those), we explicitly run the session manager in a background +task managed by LangBot's task manager. +""" + +from __future__ import annotations + +import contextlib +import typing + +from .server import LangBotMCPServer + +if typing.TYPE_CHECKING: + from ...core import app as app_module + + +# JSON-RPC-ish 401 body returned before the MCP app is reached. +_UNAUTHORIZED_BODY = b'{"error":"unauthorized","message":"A valid LangBot API key is required for MCP access."}' + + +def _extract_api_key(headers: list[tuple[bytes, bytes]]) -> str: + """Pull an API key from ASGI headers (X-API-Key or Authorization: Bearer).""" + header_map = {k.lower(): v for k, v in headers} + api_key = header_map.get(b'x-api-key', b'').decode('latin-1').strip() + if api_key: + return api_key + auth = header_map.get(b'authorization', b'').decode('latin-1').strip() + if auth.lower().startswith('bearer '): + return auth[7:].strip() + return '' + + +class MCPMount: + """Owns the MCP server and produces the dispatcher ASGI app.""" + + MCP_PATH_PREFIX = '/mcp' + + def __init__(self, ap: app_module.Application) -> None: + self.ap = ap + self.server = LangBotMCPServer(ap) + self._mcp_asgi = self.server.streamable_http_app() + self._lifespan_cm: typing.Any = None + + async def start_session_manager(self) -> None: + """Run the MCP session manager lifespan in the background. + + StreamableHTTPSessionManager.run() is a one-shot async context manager + (it may only be entered once). We keep it open for the process lifetime; + it is torn down when the event loop stops. + """ + cm = self.server.session_manager.run() + self._lifespan_cm = cm + await cm.__aenter__() + + async def stop_session_manager(self) -> None: + if self._lifespan_cm is not None: + with contextlib.suppress(Exception): + await self._lifespan_cm.__aexit__(None, None, None) + self._lifespan_cm = None + + def _is_mcp_path(self, path: str) -> bool: + return path == self.MCP_PATH_PREFIX or path.startswith(self.MCP_PATH_PREFIX + '/') + + def wrap(self, quart_asgi: typing.Callable) -> typing.Callable: + """Return a dispatcher ASGI app fronting ``quart_asgi``.""" + mcp_asgi = self._mcp_asgi + verify_api_key = self.ap.apikey_service.verify_api_key + is_mcp_path = self._is_mcp_path + + async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def] + # Pass through non-HTTP scopes (lifespan, websocket) to Quart so its + # own startup/shutdown and websocket routes keep working. + if scope['type'] != 'http' or not is_mcp_path(scope.get('path', '')): + await quart_asgi(scope, receive, send) + return + + # Authenticate MCP HTTP requests with a LangBot API key. + api_key = _extract_api_key(scope.get('headers', [])) + authorized = False + if api_key: + with contextlib.suppress(Exception): + authorized = await verify_api_key(api_key) + + if not authorized: + await send( + { + 'type': 'http.response.start', + 'status': 401, + 'headers': [ + (b'content-type', b'application/json'), + (b'www-authenticate', b'Bearer'), + ], + } + ) + await send({'type': 'http.response.body', 'body': _UNAUTHORIZED_BODY}) + return + + await mcp_asgi(scope, receive, send) + + return dispatcher diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py new file mode 100644 index 0000000..95630bb --- /dev/null +++ b/src/langbot/pkg/api/mcp/server.py @@ -0,0 +1,204 @@ +"""LangBot MCP server definition. + +Wraps a curated subset of LangBot's HTTP service API as MCP tools. Tools call +the existing service layer directly (not the HTTP API over the network), so the +MCP surface stays aligned with the API by construction. + +IMPORTANT: when you add, remove, or change an HTTP API endpoint that should be +agent-accessible, update the corresponding MCP tool here AND the skills under +``skills/`` (see AGENTS.md). The MCP tool surface and the API must stay aligned. + +Scope (first version): core read operations plus the most common writes for +bots, pipelines, LLM/embedding models, knowledge bases, MCP servers, skills, +and read-only system info. This intentionally does NOT expose every one of the +~25 HTTP route groups — that keeps the agent surface small, safe, and +maintainable. Extend deliberately. +""" + +from __future__ import annotations + +import json +import typing + +from mcp.server.fastmcp import FastMCP + +if typing.TYPE_CHECKING: + from ...core import app as app_module + + +INSTRUCTIONS = """\ +This MCP server manages a LangBot instance. LangBot is an LLM-native instant +messaging bot platform. Use these tools to inspect and manage bots, pipelines, +models, knowledge bases, MCP servers, and skills. + +Authentication uses a LangBot API key (web-UI-created `lbk_...` key or the +global API key from config.yaml), passed as the `X-API-Key` header or +`Authorization: Bearer `. + +Prefer the `list_*` / `get_*` tools to discover resources before mutating. All +identifiers are UUIDs unless noted. Mutating tools take JSON objects matching +the same shape as the LangBot HTTP API request bodies. +""" + + +def _dump(value: typing.Any) -> str: + """Serialize a tool result to a compact JSON string for the agent.""" + return json.dumps(value, ensure_ascii=False, default=str) + + +class LangBotMCPServer: + """Builds and owns the FastMCP instance for LangBot.""" + + def __init__(self, ap: app_module.Application) -> None: + self.ap = ap + # Stateless HTTP so the server does not need sticky sessions behind a + # load balancer; json_response keeps responses simple (no SSE stream + # required for unary tool calls). + self.mcp = FastMCP( + name='LangBot', + instructions=INSTRUCTIONS, + stateless_http=True, + json_response=True, + ) + self._register_tools() + + # ------------------------------------------------------------------ # + # Tool registration + # ------------------------------------------------------------------ # + def _register_tools(self) -> None: + ap = self.ap + mcp = self.mcp + + # ----- System (read-only) -------------------------------------- # + @mcp.tool(description='Get basic LangBot system/runtime information (version, edition).') + async def get_system_info() -> str: + version = None + try: + version = ap.ver_mgr.get_current_version() + except Exception: + pass + data = { + 'version': version, + 'edition': ap.instance_config.data.get('system', {}).get('edition'), + 'instance_id': ap.instance_config.data.get('system', {}).get('instance_id'), + } + return _dump(data) + + # ----- Bots ---------------------------------------------------- # + @mcp.tool(description='List all messaging-platform bots. Secrets are redacted.') + async def list_bots() -> str: + return _dump(await ap.bot_service.get_bots(include_secret=False)) + + @mcp.tool(description='Get a single bot by its UUID. Secrets are redacted.') + async def get_bot(bot_uuid: str) -> str: + return _dump(await ap.bot_service.get_bot(bot_uuid, include_secret=False)) + + @mcp.tool( + description=( + 'Create a bot. `bot_data` is a JSON object matching the LangBot ' + 'POST /api/v1/platform/bots body (e.g. name, adapter, config). ' + 'Returns the new bot UUID.' + ) + ) + async def create_bot(bot_data: dict) -> str: + return _dump({'uuid': await ap.bot_service.create_bot(bot_data)}) + + @mcp.tool(description='Update a bot by UUID. `bot_data` matches the PUT bot body.') + async def update_bot(bot_uuid: str, bot_data: dict) -> str: + await ap.bot_service.update_bot(bot_uuid, bot_data) + return _dump({'ok': True}) + + @mcp.tool(description='Delete a bot by UUID.') + async def delete_bot(bot_uuid: str) -> str: + await ap.bot_service.delete_bot(bot_uuid) + return _dump({'ok': True}) + + # ----- Pipelines ----------------------------------------------- # + @mcp.tool(description='List all pipelines.') + async def list_pipelines() -> str: + return _dump(await ap.pipeline_service.get_pipelines()) + + @mcp.tool(description='Get a single pipeline by UUID.') + async def get_pipeline(pipeline_uuid: str) -> str: + return _dump(await ap.pipeline_service.get_pipeline(pipeline_uuid)) + + @mcp.tool( + description=( + 'Create a pipeline. `pipeline_data` matches the LangBot POST ' + '/api/v1/pipelines body. Returns the new pipeline UUID.' + ) + ) + async def create_pipeline(pipeline_data: dict) -> str: + return _dump({'uuid': await ap.pipeline_service.create_pipeline(pipeline_data)}) + + @mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.') + async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str: + await ap.pipeline_service.update_pipeline(pipeline_uuid, pipeline_data) + return _dump({'ok': True}) + + @mcp.tool(description='Delete a pipeline by UUID.') + async def delete_pipeline(pipeline_uuid: str) -> str: + await ap.pipeline_service.delete_pipeline(pipeline_uuid) + return _dump({'ok': True}) + + # ----- Models -------------------------------------------------- # + @mcp.tool(description='List all configured LLM models. Secrets are redacted.') + async def list_llm_models() -> str: + return _dump(await ap.llm_model_service.get_llm_models(include_secret=False)) + + @mcp.tool(description='Get a single LLM model by UUID.') + async def get_llm_model(model_uuid: str) -> str: + return _dump(await ap.llm_model_service.get_llm_model(model_uuid)) + + @mcp.tool(description='List all configured embedding models.') + async def list_embedding_models() -> str: + return _dump(await ap.embedding_models_service.get_embedding_models()) + + @mcp.tool(description='List all model providers (OpenAI-compatible, Anthropic, etc.).') + async def list_model_providers() -> str: + return _dump(await ap.provider_service.get_providers()) + + # ----- Knowledge bases ----------------------------------------- # + @mcp.tool(description='List all knowledge bases (RAG).') + async def list_knowledge_bases() -> str: + return _dump(await ap.knowledge_service.get_knowledge_bases()) + + @mcp.tool(description='Get a single knowledge base by UUID.') + async def get_knowledge_base(kb_uuid: str) -> str: + return _dump(await ap.knowledge_service.get_knowledge_base(kb_uuid)) + + @mcp.tool( + description=('Retrieve (semantic search) from a knowledge base. Returns the matched chunks for `query`.') + ) + async def retrieve_knowledge_base(kb_uuid: str, query: str) -> str: + return _dump(await ap.knowledge_service.retrieve_knowledge_base(kb_uuid, query)) + + # ----- MCP servers (LangBot as MCP client) --------------------- # + @mcp.tool( + description=( + 'List external MCP servers registered in LangBot (the servers LangBot itself connects to as a client).' + ) + ) + async def list_mcp_servers() -> str: + return _dump(await ap.mcp_service.get_mcp_servers()) + + # ----- Skills -------------------------------------------------- # + @mcp.tool(description='List installed skills.') + async def list_skills() -> str: + return _dump(await ap.skill_service.list_skills()) + + @mcp.tool(description='Get a single skill by name.') + async def get_skill(skill_name: str) -> str: + return _dump(await ap.skill_service.get_skill(skill_name)) + + # ------------------------------------------------------------------ # + # ASGI app + # ------------------------------------------------------------------ # + def streamable_http_app(self): # type: ignore[no-untyped-def] + """Return the Starlette ASGI app serving MCP over streamable HTTP at /mcp.""" + return self.mcp.streamable_http_app() + + @property + def session_manager(self): # type: ignore[no-untyped-def] + """Expose the session manager so its lifespan can be run by the host.""" + return self.mcp.session_manager diff --git a/src/langbot/pkg/box/__init__.py b/src/langbot/pkg/box/__init__.py new file mode 100644 index 0000000..de63941 --- /dev/null +++ b/src/langbot/pkg/box/__init__.py @@ -0,0 +1,5 @@ +"""LangBot Box runtime package.""" + +from .workspace import BoxWorkspaceSession + +__all__ = ['BoxWorkspaceSession'] diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py new file mode 100644 index 0000000..2257910 --- /dev/null +++ b/src/langbot/pkg/box/connector.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import asyncio +import json +import os +import sys +import typing +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from langbot_plugin.entities.io.actions.enums import CommonAction +from langbot_plugin.runtime.io.handler import Handler +from langbot_plugin.runtime.io.connection import Connection + +from langbot_plugin.box.client import ActionRPCBoxClient +from langbot_plugin.box.errors import BoxRuntimeUnavailableError +from langbot_plugin.box.actions import LangBotToBoxAction + +from ..utils import platform +from ..utils.managed_runtime import ManagedRuntimeConnector + +if TYPE_CHECKING: + from ..core import app as core_app + + +# Default Docker Compose service name for the standalone Box container. +_DOCKER_BOX_HOST = 'langbot_box' +_DEFAULT_PORT = 5410 + +_HEARTBEAT_INTERVAL_SEC = 20 + +# Top-level keys under ``box`` that are LangBot-internal and should not be +# forwarded to the Box runtime. +_INTERNAL_BOX_CONFIG_KEYS = frozenset({'runtime'}) + + +def _get_box_config(ap) -> dict: + """Return the 'box' section from instance config. + + Environment-variable overrides are handled uniformly by + ``LoadConfigStage._apply_env_overrides_to_config`` using the + ``SECTION__SUBSECTION__KEY`` convention (e.g. ``BOX__LOCAL__HOST_ROOT``, + ``BOX__LOCAL__ALLOWED_MOUNT_ROOTS="/a,/b"``) before this is read, so no + box-specific env parsing is needed here. + """ + instance_config = getattr(ap, 'instance_config', None) + config_data = getattr(instance_config, 'data', {}) if instance_config is not None else {} + return dict(config_data.get('box', {}) or {}) + + +def _get_runtime_endpoint(box_cfg: dict) -> str: + runtime_cfg = box_cfg.get('runtime') or {} + return str(runtime_cfg.get('endpoint', '')).strip() + + +def _filter_config_for_runtime(box_cfg: dict) -> dict: + return {k: v for k, v in box_cfg.items() if k not in _INTERNAL_BOX_CONFIG_KEYS} + + +def resolve_box_ws_relay_url(ap: core_app.Application) -> str: + """Derive the WS relay base URL used for managed-process attach. + + The WS relay serves the ``/v1/sessions/{id}/managed-process/ws`` endpoint + on the *relay* port (default 5410). + """ + box_cfg = _get_box_config(ap) + + # Explicit runtime endpoint takes precedence. The config value is a base + # URL; endpoint-specific paths are appended by the SDK client. + endpoint = _get_runtime_endpoint(box_cfg) + if endpoint: + parsed = urlparse(endpoint) + scheme = parsed.scheme or 'ws' + if scheme == 'ws': + scheme = 'http' + elif scheme == 'wss': + scheme = 'https' + host = parsed.hostname or '127.0.0.1' + port = parsed.port or _DEFAULT_PORT + return f'{scheme}://{host}:{port}' + + # In Docker, relay lives on the box runtime container. + if platform.get_platform() == 'docker': + return f'http://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}' + + return f'http://127.0.0.1:{_DEFAULT_PORT}' + + +class BoxRuntimeConnector(ManagedRuntimeConnector): + """Connect to the Box runtime via action RPC. + + Transport decision (mirrors Plugin runtime logic): + 1. Docker / --standalone-box / explicit runtime.endpoint -> WebSocket to external Box process + 2. Windows (non-Docker) -> subprocess + WebSocket (Windows lacks async stdio pipe) + 3. Unix / macOS -> subprocess + stdio pipe + """ + + def __init__( + self, + ap: core_app.Application, + runtime_disconnect_callback: typing.Callable[ + ['BoxRuntimeConnector'], typing.Coroutine[typing.Any, typing.Any, None] + ] + | None = None, + ): + super().__init__(ap) + self.runtime_disconnect_callback = runtime_disconnect_callback + self.configured_runtime_endpoint = self._load_configured_runtime_endpoint() + self.ws_relay_base_url = resolve_box_ws_relay_url(ap) + self.client = ActionRPCBoxClient(logger=ap.logger) + + self._handler: Handler | None = None + self._handler_task: asyncio.Task | None = None + self._ctrl_task: asyncio.Task | None = None + self._heartbeat_task: asyncio.Task | None = None + + # Parse the relay URL once for reuse. + parsed = urlparse(self.ws_relay_base_url) + self._relay_host = parsed.hostname or '127.0.0.1' + self._relay_port = parsed.port or _DEFAULT_PORT + self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap)) + + def uses_websocket(self) -> bool: + """Whether the connector should use WebSocket to reach the Box runtime. + + True when: + - Running inside Docker (Box runtime is a separate container) + - The ``--standalone-box`` CLI flag was passed + - An explicit ``runtime.endpoint`` was configured + + When this is True the Box runtime lives in a separate process with its + own filesystem view (container, pod sidecar, or remote host), so paths + it reports (e.g. skill ``package_root``) are NOT resolvable on the + LangBot side. When False, Box runs as a stdio child process that shares + LangBot's filesystem. + """ + return bool( + self.configured_runtime_endpoint + or platform.get_platform() == 'docker' + or platform.use_websocket_to_connect_box_runtime() + ) + + # Backwards-compatible private alias. + def _uses_websocket(self) -> bool: + return self.uses_websocket() + + async def initialize(self) -> None: + if self._uses_websocket(): + if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint: + await self._start_subprocess_then_ws() + else: + await self._connect_remote_ws() + else: + await self._start_local_stdio() + + # Start heartbeat after successful connection + if self._heartbeat_task is None: + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + + # -- heartbeat ----------------------------------------------------------- + + async def _heartbeat_loop(self) -> None: + """Periodically ping the Box runtime to detect silent disconnections.""" + while True: + await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC) + try: + await self.ping() + self.ap.logger.debug('Heartbeat to Box runtime success.') + except Exception as e: + self.ap.logger.debug(f'Failed to heartbeat to Box runtime: {e}') + + async def ping(self) -> None: + if self._handler is None: + raise BoxRuntimeUnavailableError('Box runtime is not connected') + await self._handler.call_action(CommonAction.PING, {}) + + # -- transport paths ----------------------------------------------------- + + async def _start_local_stdio(self) -> None: + """Launch box server as subprocess and connect via stdio (Unix/macOS).""" + from langbot_plugin.runtime.io.controllers.stdio.client import StdioClientController + + self.ap.logger.info('Use stdio to connect to box runtime') + python_path = sys.executable + env = os.environ.copy() + if self._filtered_box_config: + env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config) + + connected = asyncio.Event() + connect_error: list[Exception] = [] + + ctrl = StdioClientController( + command=python_path, + # Launched through the same CLI entry point as the plugin runtime + # (cli.__init__ ); `-s` selects the stdio transport, + # mirroring `rt -s`. + args=['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', str(self._relay_port)], + env=env, + ) + self._ctrl_task = asyncio.create_task( + ctrl.run(self._make_connection_callback('stdio', connected, connect_error)) + ) + + try: + await asyncio.wait_for(connected.wait(), timeout=30.0) + except asyncio.TimeoutError: + raise BoxRuntimeUnavailableError('box runtime subprocess did not connect in time') + + if connect_error: + raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}') + + self._subprocess = ctrl.process + + async def _start_subprocess_then_ws(self) -> None: + """Launch box server as detached subprocess, then connect via WS (Windows).""" + self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws') + + env = os.environ.copy() + if self._filtered_box_config: + env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config) + + python_path = sys.executable + # Launched through the same CLI entry point as the plugin runtime + # (cli.__init__ ); no flag => WebSocket transport. + self.runtime_subprocess = await asyncio.create_subprocess_exec( + python_path, + '-m', + 'langbot_plugin.cli.__init__', + 'box', + '--ws-control-port', + str(self._relay_port), + env=env, + ) + self.runtime_subprocess_task = asyncio.create_task(self.runtime_subprocess.wait()) + + ws_url = f'ws://localhost:{self._relay_port}/rpc/ws' + await self._connect_ws(ws_url, '(windows) WebSocket') + + async def _connect_remote_ws(self) -> None: + """Connect to a remote (or Docker) box server via WebSocket.""" + ws_url = self._resolve_rpc_ws_url() + self.ap.logger.info(f'Use WebSocket to connect to box runtime ({ws_url})') + await self._connect_ws(ws_url, 'WebSocket') + + # -- helpers ------------------------------------------------------------- + + def _resolve_rpc_ws_url(self) -> str: + """Determine the action-RPC WebSocket URL. + + All endpoints share a single port; action RPC is at ``/rpc/ws``. + """ + if self.configured_runtime_endpoint: + base = self.configured_runtime_endpoint.rstrip('/') + parsed = urlparse(base) + scheme = parsed.scheme or 'ws' + if scheme in ('http', 'https'): + scheme = 'wss' if scheme == 'https' else 'ws' + host = parsed.hostname or '127.0.0.1' + port = parsed.port or _DEFAULT_PORT + return f'{scheme}://{host}:{port}/rpc/ws' + + if platform.get_platform() == 'docker': + return f'ws://{_DOCKER_BOX_HOST}:{_DEFAULT_PORT}/rpc/ws' + + return f'ws://localhost:{self._relay_port}/rpc/ws' + + async def _connect_ws(self, ws_url: str, transport_name: str) -> None: + """Shared WebSocket connection procedure.""" + from langbot_plugin.runtime.io.controllers.ws.client import WebSocketClientController + + connected = asyncio.Event() + connect_error: list[Exception] = [] + + async def on_connect_failed(ctrl, exc): + if exc is not None: + self.ap.logger.error(f'Failed to connect to Box runtime ({ws_url}): {exc}') + else: + self.ap.logger.error(f'Failed to connect to Box runtime ({ws_url}), trying to reconnect...') + connect_error.append(exc or BoxRuntimeUnavailableError('ws connection failed')) + connected.set() + if self.runtime_disconnect_callback is not None: + await self.runtime_disconnect_callback(self) + + ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed) + self._ctrl_task = asyncio.create_task( + ctrl.run(self._make_connection_callback(transport_name, connected, connect_error)) + ) + + try: + await asyncio.wait_for(connected.wait(), timeout=30.0) + except asyncio.TimeoutError: + raise BoxRuntimeUnavailableError(f'box runtime ws connection timed out ({ws_url})') + + if connect_error: + raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}') + + def _make_connection_callback( + self, + transport_name: str, + connected: asyncio.Event, + connect_error: list[Exception], + ): + async def new_connection_callback(connection: Connection) -> None: + handler = Handler(connection) + self._handler = handler + self.client.set_handler(handler) + self._handler_task = asyncio.create_task(handler.run()) + try: + await handler.call_action(CommonAction.PING, {}) + if self._filtered_box_config: + await handler.call_action(LangBotToBoxAction.INIT, self._filtered_box_config) + self.ap.logger.debug('Sent box configuration to Box runtime via INIT.') + self.ap.logger.info(f'Connected to Box runtime via {transport_name}.') + connected.set() + await self._handler_task + except Exception as exc: + if not connected.is_set(): + connect_error.append(exc) + connected.set() + return + + # If we reach here, handler.run() returned normally (connection + # closed) or raised after the initial handshake succeeded. + # Either way, treat it as a disconnect. + if connected.is_set(): + if self._uses_websocket(): + self.ap.logger.error('Disconnected from Box runtime, trying to reconnect...') + if self.runtime_disconnect_callback is not None: + await self.runtime_disconnect_callback(self) + else: + self.ap.logger.error( + 'Disconnected from Box runtime via stdio. ' + 'Cannot automatically reconnect — please restart LangBot.' + ) + + return new_connection_callback + + # -- lifecycle ----------------------------------------------------------- + + def dispose(self) -> None: + if self._heartbeat_task is not None: + self._heartbeat_task.cancel() + self._heartbeat_task = None + + if self._handler_task is not None: + self._handler_task.cancel() + self._handler_task = None + + if self._ctrl_task is not None: + self._ctrl_task.cancel() + self._ctrl_task = None + + # stdio-managed subprocess (stored as self._subprocess by _start_local_stdio) + if hasattr(self, '_subprocess') and self._subprocess is not None and self._subprocess.returncode is None: + self.ap.logger.info('Terminating managed box runtime process...') + self._subprocess.terminate() + + # Subprocess launched by ManagedRuntimeConnector._start_runtime_subprocess (Windows path) + self._dispose_subprocess() + + # -- config helpers ------------------------------------------------------ + + def _load_configured_runtime_endpoint(self) -> str: + return _get_runtime_endpoint(_get_box_config(self.ap)) diff --git a/src/langbot/pkg/box/policy.py b/src/langbot/pkg/box/policy.py new file mode 100644 index 0000000..15f4c45 --- /dev/null +++ b/src/langbot/pkg/box/policy.py @@ -0,0 +1,98 @@ +"""Three-layer security policy for LangBot Box. + +The design separates concerns into three independent layers, aligned with +OpenCode / OpenClaw patterns: + +1. **SandboxPolicy** – *where* tools run (host vs sandbox). +2. **ToolPolicy** – *which* tools are allowed (allow/deny lists). +3. **ElevatedPolicy** – *whether* a single exec call may temporarily + escape the default sandbox boundary. + +These three layers are orthogonal: +- ToolPolicy is a hard boundary; ``elevated`` cannot bypass a denied tool. +- SandboxPolicy decides the default execution location. +- ElevatedPolicy only affects ``exec`` and only when the framework allows it. +""" + +from __future__ import annotations + +import enum +from typing import Sequence + + +# ── Layer 1: Sandbox Policy ────────────────────────────────────────── + + +class SandboxMode(str, enum.Enum): + """Determines when agent execution is routed through the sandbox.""" + + OFF = 'off' + """Sandbox disabled; all exec runs on the host.""" + + NON_DEFAULT = 'non_default' + """Only non-default sessions are sandboxed (e.g. sub-agents, MCP).""" + + ALL = 'all' + """Every agent exec call is routed through the sandbox.""" + + +class SandboxPolicy: + """Decides whether a given execution context should use the sandbox.""" + + def __init__(self, mode: SandboxMode = SandboxMode.ALL): + self.mode = mode + + def should_sandbox(self, *, is_default_session: bool = True) -> bool: + if self.mode == SandboxMode.OFF: + return False + if self.mode == SandboxMode.ALL: + return True + # NON_DEFAULT: sandbox everything except the default session + return not is_default_session + + +# ── Layer 2: Tool Policy ───────────────────────────────────────────── + + +class ToolPolicy: + """Controls which tools are available to the current agent/session. + + Rules: + - ``deny`` always takes precedence over ``allow``. + - An empty ``allow`` list means "all tools allowed" (no allowlist filter). + - ``elevated`` cannot bypass a denied tool. + """ + + def __init__( + self, + allow: Sequence[str] = (), + deny: Sequence[str] = (), + ): + self._allow: frozenset[str] = frozenset(allow) + self._deny: frozenset[str] = frozenset(deny) + + def is_tool_allowed(self, tool_name: str) -> bool: + if tool_name in self._deny: + return False + if self._allow and tool_name not in self._allow: + return False + return True + + +# ── Layer 3: Elevated Policy ───────────────────────────────────────── + + +class ElevatedPolicy: + """Controls whether ``exec`` may request temporary privilege escalation. + + ``elevated`` only applies to the ``exec`` tool. It means "run this + command outside the default sandbox boundary" (e.g. with network, or + on the host). The framework decides whether to honor the request. + """ + + def __init__(self, *, allow_elevated: bool = False, require_approval: bool = True): + self.allow_elevated = allow_elevated + self.require_approval = require_approval + + def is_elevation_permitted(self) -> bool: + return self.allow_elevated diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py new file mode 100644 index 0000000..ca30eb9 --- /dev/null +++ b/src/langbot/pkg/box/service.py @@ -0,0 +1,1388 @@ +from __future__ import annotations + +import asyncio +import collections +import datetime as _dt +import enum +import json +import os +from typing import TYPE_CHECKING + +import pydantic + +from langbot_plugin.box.client import BoxRuntimeClient +from .connector import BoxRuntimeConnector, _get_box_config +from ..telemetry import features as telemetry_features +from langbot_plugin.box.errors import BoxError, BoxValidationError +from langbot_plugin.box.models import ( + BUILTIN_PROFILES, + BoxExecutionResult, + BoxManagedProcessInfo, + BoxManagedProcessSpec, + BoxProfile, + BoxSpec, +) + +_INT_ADAPTER = pydantic.TypeAdapter(int) +_UTC = _dt.timezone.utc +_MAX_RECENT_ERRORS = 50 +_MIB = 1024 * 1024 + + +def _is_path_under(path: str, root: str) -> bool: + """Check whether *path* equals *root* or is a child of *root*.""" + return path == root or path.startswith(f'{root}{os.sep}') + + +if TYPE_CHECKING: + from ..core import app as core_app + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +class BoxService: + def __init__( + self, + ap: core_app.Application, + client: BoxRuntimeClient | None = None, + output_limit_chars: int = 4000, + ): + self.ap = ap + self._enabled = self._load_enabled() + self._runtime_connector: BoxRuntimeConnector | None = None + if client is None: + # Always construct a connector — its __init__ is side-effect free + # (no I/O, no subprocess). When ``box.enabled = false`` we simply + # skip ``connector.initialize()`` so no connection is attempted. + self._runtime_connector = BoxRuntimeConnector(ap, runtime_disconnect_callback=self._on_runtime_disconnect) + client = self._runtime_connector.client + self.client = client + self.output_limit_chars = output_limit_chars + self.host_root = self._load_host_root() + self.allowed_mount_roots = self._load_allowed_mount_roots() + self.default_workspace = self._load_default_workspace() + self.profile = self._load_profile() + self.custom_image = self._load_custom_image() + self.workspace_quota_mb = self._load_workspace_quota_mb() + self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS) + self._shutdown_task = None + self._available = False + self._connector_error: str = '' + self._reconnecting = False + # Optional explicit override for shares_filesystem_with_box. None means + # "derive from the connector transport". Set by tests / embedders that + # know the real LangBot<->Box filesystem topology. + self._shares_filesystem_with_box_override: bool | None = None + + @property + def enabled(self) -> bool: + """Whether Box is enabled in config. False means the operator has + deliberately turned the sandbox off via ``box.enabled = false``. + Disabled and "enabled but unavailable" are reported as the same + ``available = False`` to consumers, but distinguished in get_status.""" + return self._enabled + + async def initialize(self): + if not self._enabled: + # Disabled by config: do NOT connect to a remote runtime, do NOT + # fork a stdio subprocess. Every consumer of box_service should + # gate on ``available`` and degrade gracefully. + self._available = False + self._connector_error = 'Box runtime is disabled in config (box.enabled = false)' + self.ap.logger.info( + 'Box runtime disabled by config; sandbox features (exec/read/write/edit, ' + 'skill add/edit, stdio MCP) will be unavailable.' + ) + return + try: + if self._runtime_connector is not None: + await self._runtime_connector.initialize() + else: + await self.client.initialize() + self._ensure_default_workspace() + self._available = True + self._connector_error = '' + self.ap.logger.info( + f'LangBot Box runtime initialized: profile={self.profile.name} ' + f'default_workspace={self.default_workspace or "(none)"}' + ) + await self._purge_attachment_dirs() + except Exception as exc: + self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}') + self._available = False + self._connector_error = str(exc) + + async def _on_runtime_disconnect(self, connector: BoxRuntimeConnector) -> None: + """Called by the connector when the Box runtime connection drops. + + Spawns a background reconnection loop so the caller is not blocked. + Skipped entirely when Box is disabled by config — that path should + never have connected in the first place. + """ + if not self._enabled: + return + if self._reconnecting: + return # Another reconnect loop is already running + self._reconnecting = True + self._available = False + self._connector_error = 'Disconnected from Box runtime' + self.ap.logger.warning('Box runtime disconnected, sandbox features temporarily disabled.') + asyncio.create_task(self._reconnect_loop(connector)) + + async def _reconnect_loop(self, connector: BoxRuntimeConnector) -> None: + """Retry reconnection with exponential backoff (3s → 60s max).""" + delay = 3 + max_delay = 60 + try: + while True: + self.ap.logger.info(f'Attempting to reconnect to Box runtime in {delay}s...') + await asyncio.sleep(delay) + try: + connector.dispose() + await connector.initialize() + self._available = True + self._connector_error = '' + self.ap.logger.info('Box runtime reconnected, sandbox features restored.') + return + except Exception as exc: + self._connector_error = str(exc) + self.ap.logger.warning(f'Box runtime reconnection failed: {exc}') + delay = min(delay * 2, max_delay) + finally: + self._reconnecting = False + + @property + def available(self) -> bool: + return self._available + + @property + def shares_filesystem_with_box(self) -> bool: + """Whether LangBot and the Box runtime share a filesystem view. + + This is True only when Box runs as a local stdio child process of + LangBot (same container/host). In that case paths the Box runtime + reports — notably skill ``package_root`` — resolve identically on the + LangBot side, so LangBot may validate them against its own filesystem. + + It is False for every separated deployment (Docker Compose, k8s + sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``), + where the Box runtime owns its own filesystem and LangBot must trust + the paths it reports rather than checking them locally. + + When Box is wired up with an injected client (tests, custom embeds) + there is no connector to introspect; we conservatively report False so + LangBot never wrongly drops Box-reported skills. An explicit override + can be set via ``_shares_filesystem_with_box`` (used by tests and any + embedder that knows the real topology). + """ + if self._shares_filesystem_with_box_override is not None: + return self._shares_filesystem_with_box_override + if self._runtime_connector is None: + return False + return not self._runtime_connector.uses_websocket() + + async def execute_spec_payload( + self, + spec_payload: dict, + query: pipeline_query.Query, + *, + skip_host_mount_validation: bool = False, + ) -> dict: + if not self._available: + raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.') + try: + spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation) + except BoxError as exc: + self._record_error(exc, query) + raise + self.ap.logger.info( + 'LangBot Box request: ' + f'query_id={query.query_id} ' + f'spec={json.dumps(self._summarize_spec(spec), ensure_ascii=False)}' + ) + try: + await self._enforce_workspace_quota(spec, phase='before execution') + except BoxError as exc: + self._record_error(exc, query) + raise + try: + result = await self.client.execute(spec) + except BoxError as exc: + self._record_error(exc, query) + raise + try: + await self._enforce_workspace_quota(spec, phase='after execution') + except BoxError as exc: + await self._cleanup_exceeded_session(spec) + self._record_error(exc, query) + raise + self.ap.logger.info( + 'LangBot Box result: ' + f'query_id={query.query_id} ' + f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}' + ) + telemetry_features.increment(query, 'sandbox', 'execs') + return self._serialize_result(result) + + def resolve_box_session_id(self, query: pipeline_query.Query) -> str: + """Resolve the Box session_id from the pipeline's template and query variables. + + When ``system.limitation.force_box_session_id_template`` is set to a + non-empty value, that template overrides whatever the pipeline + configured. This is the authoritative SaaS guard: it runs on every + ``exec`` call, so a tenant cannot escape a single shared sandbox even + by editing the pipeline config directly through the API (which only + gates the web UI). + """ + forced_template = self._forced_box_session_id_template() + if forced_template: + template = forced_template + else: + template = ( + (query.pipeline_config or {}) + .get('ai', {}) + .get('local-agent', {}) + .get('box-session-id-template', '{launcher_type}_{launcher_id}') + ) + variables = dict(query.variables or {}) + launcher_type = getattr(query, 'launcher_type', None) + if hasattr(launcher_type, 'value'): + launcher_type = launcher_type.value + launcher_id = getattr(query, 'launcher_id', None) + sender_id = getattr(query, 'sender_id', None) + query_id = getattr(query, 'query_id', None) + + variables.setdefault('query_id', str(query_id or 'unknown')) + variables.setdefault('launcher_type', str(launcher_type or 'query')) + variables.setdefault('launcher_id', str(launcher_id or query_id or 'unknown')) + variables.setdefault('sender_id', str(sender_id or launcher_id or query_id or 'unknown')) + variables.setdefault('global', 'global') + return template.format_map(collections.defaultdict(lambda: 'unknown', variables)) + + def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]: + """Build extra_mounts entries for all pipeline-bound skills. + + This ensures that when a container is first created it already has + all skill packages mounted, regardless of which skill is currently + activated. + + Path validation is filesystem-topology dependent. When LangBot and the + Box runtime share a filesystem (local stdio mode), a skill whose + ``package_root`` is missing or no longer a directory is skipped with a + warning instead of being passed through to the backend. Without that + guard the three backends behave inconsistently on a stale mount: nsjail + refuses to start the sandbox (failing every exec in the session), + Docker silently auto-creates a root-owned empty directory on the host, + and E2B silently skips the upload — none of which surfaces an + actionable error. + + When Box runs as a separate process (Docker Compose, k8s sidecar, + ``--standalone-box``, or a remote ``runtime.endpoint``), the + ``package_root`` reported by ``list_skills`` is the Box runtime's own + filesystem path and is NOT resolvable on the LangBot side. Validating + it locally would wrongly drop every skill, so LangBot trusts the path + and lets the Box runtime resolve it. The Box runtime only ever reports + skills it discovered on its own filesystem, so the path is valid there + by construction. + """ + skill_mgr = getattr(self.ap, 'skill_mgr', None) + if skill_mgr is None: + return [] + + from ..provider.tools.loaders import skill as skill_loader + + validate_locally = self.shares_filesystem_with_box + + visible_skills = skill_loader.get_visible_skills(self.ap, query) + mounts: list[dict] = [] + for skill_name, skill_data in visible_skills.items(): + package_root = str(skill_data.get('package_root', '') or '').strip() + if not package_root: + continue + if validate_locally and not os.path.isdir(package_root): + self.ap.logger.warning( + f'Skill "{skill_name}" package_root missing on filesystem ' + f'({package_root}); skipping mount to prevent sandbox failures. ' + f'The skill cache may be stale — consider reloading skills.' + ) + continue + mounts.append( + { + 'host_path': package_root, + 'mount_path': f'/workspace/.skills/{skill_name}', + 'mode': 'rw', + } + ) + return mounts + + async def execute_tool(self, parameters: dict, query: pipeline_query.Query) -> dict: + """Execute an agent-facing ``exec`` tool call. + + Translates the agent-facing ``command`` field to the internal + ``BoxSpec.cmd`` field and injects the session id from the query. + """ + spec_payload: dict = {'cmd': parameters['command']} + + # Pass through allowed agent-facing fields + for key in ('workdir', 'timeout_sec', 'env'): + if key in parameters: + spec_payload[key] = parameters[key] + + # Inject context the agent must not control + spec_payload.setdefault('session_id', self.resolve_box_session_id(query)) + + # Mount all pipeline-bound skills so they are available in the container + if 'extra_mounts' not in spec_payload: + spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query) + + return await self.execute_spec_payload(spec_payload, query) + + # ── Attachment passthrough (inbound / outbound) ────────────────── + # + # IM/webchat attachments (images, voices, files) reach the LLM as + # multimodal content, but historically never landed on the sandbox + # filesystem, so the agent's exec/read/write tools could not operate on + # them. Conversely, files the agent produced inside the sandbox were + # never surfaced back to the user. These two helpers close both gaps: + # + # inbound : message_chain attachments -> /workspace/inbox// + # outbound : /workspace/outbox// -> reply MessageChain + # + # Transfer prefers DIRECT HOST FILESYSTEM access to the bind-mounted + # workspace (default_workspace on the host maps to /workspace inside the + # container), which has no size limit. This covers the local docker / + # nsjail / stdio backends. For backends where the workspace is NOT visible + # on the LangBot host (E2B, an external remote runtime.endpoint), it falls + # back to a base64-through-exec round-trip. The exec channel can only move + # small files reliably — the docker backend passes the command as a single + # argv (ARG_MAX) and exec stdout is truncated by output_limit_chars — so + # the host path is strongly preferred and used whenever available. + + INBOX_MOUNT_DIR = '/workspace/inbox' + OUTBOX_MOUNT_DIR = '/workspace/outbox' + INBOX_SUBDIR = 'inbox' + OUTBOX_SUBDIR = 'outbox' + # Hard cap on a single attachment. The HTTP upload endpoints already cap + # uploads at 10MiB; keep parity. + _ATTACHMENT_MAX_BYTES = 10 * _MIB + # Conservative cap for the exec FALLBACK path only (ARG_MAX / stdout + # truncation). The host-filesystem path has no such limit. + _EXEC_FALLBACK_MAX_BYTES = 256 * 1024 + + def _host_query_dir(self, subdir: str, query_id) -> str | None: + """Host path for ``/workspace//`` when LangBot can + access the bind-mounted workspace directly, else ``None``. + + ``default_workspace`` is the host directory bind-mounted to + ``/workspace`` for the local docker/nsjail backends and shared + outright in stdio mode, so a file written there by LangBot is visible + to the sandbox (and vice-versa). It is ``None`` / not a local dir for + E2B and remote runtimes, where we must fall back to the exec channel. + """ + root = self.default_workspace + if not root or not os.path.isdir(root): + return None + return os.path.join(root, subdir, str(query_id)) + + async def _purge_attachment_dirs(self) -> None: + """Remove leftover inbox/outbox directories on startup. + + ``query_id`` is a process-local counter (see pipeline query pool) that + resets to 0 on every restart, so per-query attachment directories from + a previous process would otherwise be silently reused — leaking a prior + run's inbound files and re-sending stale outbound files. + + Outbox files are written by the sandbox **container**, which runs as + root over the bind-mount, so the LangBot host process (a non-root user) + cannot ``rmtree`` them. We therefore try a host-side delete first (fast, + works for host-owned inbox files) and, for anything that survives, + delete from *inside* the sandbox via exec where the container's root can + remove its own files. Best-effort: never block startup. + """ + root = self.default_workspace + if not root or not os.path.isdir(root): + return + + import shutil + + host_survivors: list[str] = [] + + def _host_purge() -> list[str]: + survivors: list[str] = [] + for subdir in (self.INBOX_SUBDIR, self.OUTBOX_SUBDIR): + path = os.path.join(root, subdir) + if not os.path.isdir(path): + continue + shutil.rmtree(path, ignore_errors=True) + if os.path.exists(path): + survivors.append(subdir) + return survivors + + try: + host_survivors = await asyncio.to_thread(_host_purge) + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.warning(f'Host-side purge of sandbox attachment dirs failed: {exc}') + host_survivors = [self.INBOX_SUBDIR, self.OUTBOX_SUBDIR] + + if not host_survivors: + self.ap.logger.info('Purged leftover sandbox attachment dirs from a previous process.') + return + + # Root-owned leftovers (container output): delete from inside the box. + targets = ' '.join(f'/workspace/{sub}' for sub in host_survivors) + try: + spec = self.build_spec({'cmd': f'rm -rf {targets}', 'session_id': '__startup_purge__', 'timeout_sec': 30}) + await self.client.execute(spec) + self.ap.logger.info( + f'Purged root-owned leftover sandbox attachment dirs via sandbox exec: {host_survivors}' + ) + except Exception as exc: + self.ap.logger.warning( + f'Failed to purge root-owned sandbox attachment dirs {host_survivors} via exec: {exc}' + ) + + @staticmethod + def _sanitize_attachment_name(name: str, fallback: str) -> str: + """Reduce an arbitrary attachment name to a safe basename. + + Strips directory separators and parent refs so a crafted file name + can never escape the inbox/outbox directory. + """ + base = os.path.basename(str(name or '').replace('\\', '/').strip()) + base = base.lstrip('.') or '' + # Drop anything that is not a conservative filename charset. + cleaned = ''.join(c for c in base if c.isalnum() or c in ('.', '_', '-', ' ')).strip() + cleaned = cleaned.replace(' ', '_') + return cleaned or fallback + + @staticmethod + async def _component_to_bytes(component) -> tuple[bytes, str] | None: + """Best-effort extraction of (bytes, mime) from a platform component. + + Handles base64, http(s) url and local path sources. Returns None when + no payload can be resolved. + """ + import base64 as _b64 + + b64 = getattr(component, 'base64', None) + if b64: + data = b64 + mime = 'application/octet-stream' + if isinstance(data, str) and data.startswith('data:'): + split_index = data.find(';base64,') + if split_index != -1: + mime = data[5:split_index] + data = data[split_index + 8 :] + try: + return _b64.b64decode(data), mime + except Exception: + return None + + url = getattr(component, 'url', None) + if url: + try: + import httpx + + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url) + resp.raise_for_status() + return resp.content, resp.headers.get('Content-Type', 'application/octet-stream') + except Exception: + return None + + path = getattr(component, 'path', None) + if path: + try: + import aiofiles + + async with aiofiles.open(path, 'rb') as f: + return await f.read(), 'application/octet-stream' + except Exception: + return None + + return None + + async def _write_files_into_sandbox( + self, + query: pipeline_query.Query, + subdir: str, + target_mount_dir: str, + files: list[tuple[str, bytes]], + ) -> list[str]: + """Write *files* (name, bytes) into the per-query directory. + + Prefers a direct host-filesystem write to the bind-mounted workspace + (no size limit). Falls back to a base64-through-exec round-trip only + when the workspace is not visible on the LangBot host (E2B / remote). + Returns the list of in-sandbox paths actually written. + """ + if not files: + return [] + + host_dir = self._host_query_dir(subdir, query.query_id) + if host_dir is not None: + return await asyncio.to_thread(self._write_files_host, host_dir, target_mount_dir, files) + + return await self._write_files_via_exec(query, target_mount_dir, files) + + def _write_files_host( + self, + host_dir: str, + target_mount_dir: str, + files: list[tuple[str, bytes]], + ) -> list[str]: + """Write attachments straight onto the bind-mounted host directory. + + Recreates the per-query directory from scratch so a reused query_id + (the webchat session uses small sequential ids) never inherits stale + files from an earlier turn. + """ + import shutil + + shutil.rmtree(host_dir, ignore_errors=True) + os.makedirs(host_dir, exist_ok=True) + written: list[str] = [] + for name, data in files: + with open(os.path.join(host_dir, name), 'wb') as fh: + fh.write(data) + written.append(f'{target_mount_dir}/{name}') + return written + + async def _write_files_via_exec( + self, + query: pipeline_query.Query, + target_dir: str, + files: list[tuple[str, bytes]], + ) -> list[str]: + """Fallback: ship files into the sandbox over the exec channel. + + Only used for backends without host-filesystem access (E2B / remote). + Each file is base64-decoded inside the sandbox. Files larger than the + conservative exec cap are skipped (ARG_MAX / stdout limits). + """ + import base64 as _b64 + import json as _json + + manifest = [] + for name, data in files: + if len(data) > self._EXEC_FALLBACK_MAX_BYTES: + self.ap.logger.warning( + f'Attachment "{name}" ({len(data)} bytes) exceeds the exec-channel ' + f'fallback limit ({self._EXEC_FALLBACK_MAX_BYTES} bytes); skipping. ' + f'Configure a host-shared workspace to transfer large files.' + ) + continue + manifest.append({'name': name, 'b64': _b64.b64encode(data).decode('ascii')}) + if not manifest: + return [] + + manifest_b64 = _b64.b64encode(_json.dumps(manifest).encode('utf-8')).decode('ascii') + script = ( + 'import base64, json, os, shutil\n' + f'target = {target_dir!r}\n' + 'shutil.rmtree(target, ignore_errors=True)\n' + 'os.makedirs(target, exist_ok=True)\n' + f'manifest = json.loads(base64.b64decode({manifest_b64!r}))\n' + 'written = []\n' + 'for item in manifest:\n' + " p = os.path.join(target, item['name'])\n" + " with open(p, 'wb') as f:\n" + " f.write(base64.b64decode(item['b64']))\n" + ' written.append(p)\n' + 'print(json.dumps(written))\n' + ) + result = await self.execute_tool( + {'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120}, + query, + ) + if not result.get('ok'): + self.ap.logger.warning( + f'Failed to write inbound attachments into sandbox via exec: ' + f'query_id={query.query_id} stderr={result.get("stderr", "")[:200]}' + ) + return [] + try: + return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1]) + except Exception: + return [] + + async def materialize_inbound_attachments(self, query: pipeline_query.Query) -> list[dict]: + """Persist message-chain attachments into the sandbox inbox. + + Returns a list of ``{path, name, type, size}`` describing what was + written, so the runner can tell the LLM the exact in-sandbox paths. + Returns ``[]`` when sandbox is unavailable or there are no attachments. + """ + if not self._available: + return [] + + import langbot_plugin.api.entities.builtin.platform.message as platform_message + + message_chain = getattr(query, 'message_chain', None) + if not message_chain: + return [] + + type_map = [ + (platform_message.Image, 'Image', 'image', 'png'), + (platform_message.Voice, 'Voice', 'voice', 'wav'), + (platform_message.File, 'File', 'file', 'bin'), + ] + + pending: list[tuple[str, bytes]] = [] + descriptors: list[dict] = [] + index = 0 + for component in message_chain: + matched = None + for cls, kind, prefix, default_ext in type_map: + if isinstance(component, cls): + matched = (kind, prefix, default_ext) + break + if matched is None: + continue + kind, prefix, default_ext = matched + + payload = await self._component_to_bytes(component) + if payload is None: + continue + data, _mime = payload + if not data or len(data) > self._ATTACHMENT_MAX_BYTES: + continue + + index += 1 + raw_name = getattr(component, 'name', None) or f'{prefix}_{index}.{default_ext}' + safe_name = self._sanitize_attachment_name(raw_name, f'{prefix}_{index}.{default_ext}') + pending.append((safe_name, data)) + descriptors.append( + { + 'name': safe_name, + 'type': kind, + 'size': len(data), + } + ) + + if not pending: + return [] + + target_dir = f'{self.INBOX_MOUNT_DIR}/{query.query_id}' + written = await self._write_files_into_sandbox(query, self.INBOX_SUBDIR, target_dir, pending) + written_basenames = {os.path.basename(p) for p in written} + + result: list[dict] = [] + for desc in descriptors: + if desc['name'] in written_basenames: + desc['path'] = f'{target_dir}/{desc["name"]}' + result.append(desc) + if result: + self.ap.logger.info( + f'Materialized {len(result)} inbound attachment(s) into sandbox: ' + f'query_id={query.query_id} dir={target_dir}' + ) + return result + + async def collect_outbound_attachments(self, query: pipeline_query.Query) -> list[dict]: + """Collect files the agent produced in the sandbox outbox. + + Reads ``/workspace/outbox//`` (recursively) — directly from + the bind-mounted host directory when available (no size limit), else + via the exec channel — returns a list of ``{type, name, base64}`` + ready to become platform message components, then clears the outbox so + a later turn in the same session does not re-send stale files. Returns + ``[]`` when nothing was produced. + """ + if not self._available: + return [] + + host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query.query_id) + if host_dir is not None: + entries = await asyncio.to_thread(self._read_outbox_host, host_dir) + else: + entries = await self._read_outbox_via_exec(query) + + attachments = self._classify_outbound_entries(entries) + + # Always clear the per-query outbox after reading — even when nothing + # was collected — so a later turn that reuses the same query_id (the + # counter resets across restarts) never inherits stale files. + await self._clear_outbox(query, host_dir) + if attachments: + self.ap.logger.info( + f'Collected {len(attachments)} outbound attachment(s) from sandbox: query_id={query.query_id}' + ) + return attachments + + def _read_outbox_host(self, host_dir: str) -> list[dict]: + """Read outbox files straight off the bind-mounted host directory.""" + import base64 as _b64 + + entries: list[dict] = [] + if not os.path.isdir(host_dir): + return entries + for root, _dirs, names in os.walk(host_dir): + for name in sorted(names): + path = os.path.join(root, name) + try: + if os.path.getsize(path) > self._ATTACHMENT_MAX_BYTES: + continue + with open(path, 'rb') as fh: + data = fh.read() + except OSError: + continue + rel = os.path.relpath(path, host_dir) + entries.append({'name': rel, 'b64': _b64.b64encode(data).decode('ascii')}) + return entries + + async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]: + """Fallback: read the outbox over the exec channel (E2B / remote). + + Note: exec stdout is truncated by ``output_limit_chars``, so this path + only reliably transfers small files. The host path is preferred. + """ + import json as _json + + target_dir = f'{self.OUTBOX_MOUNT_DIR}/{query.query_id}' + max_bytes = self._EXEC_FALLBACK_MAX_BYTES + script = ( + 'import base64, json, os\n' + f'target = {target_dir!r}\n' + f'max_bytes = {max_bytes}\n' + 'out = []\n' + 'if os.path.isdir(target):\n' + ' for root, _dirs, names in os.walk(target):\n' + ' for n in sorted(names):\n' + ' p = os.path.join(root, n)\n' + ' try:\n' + ' if os.path.getsize(p) > max_bytes:\n' + ' continue\n' + " with open(p, 'rb') as f:\n" + ' data = f.read()\n' + ' except OSError:\n' + ' continue\n' + ' rel = os.path.relpath(p, target)\n' + " out.append({'name': rel, 'b64': base64.b64encode(data).decode('ascii')})\n" + 'print(json.dumps(out))\n' + ) + result = await self.execute_tool( + {'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120}, + query, + ) + if not result.get('ok'): + return [] + try: + return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1]) + except Exception: + return [] + + async def _clear_outbox(self, query: pipeline_query.Query, host_dir: str | None) -> None: + """Empty the per-query outbox after collection. + + Tries a host-side ``rmtree`` first (fast, no container round-trip). + Outbox files are created by the sandbox container as root over the + bind-mount, so when LangBot runs as a non-root user the host delete + fails silently and the files survive — they would then be re-collected + on the next turn that reuses the same query_id. So if anything survives + the host delete, clear it from *inside* the sandbox via exec, where the + container's root can remove its own files. Best-effort: never raise + into the pipeline. + """ + target_dir = f'{self.OUTBOX_MOUNT_DIR}/{query.query_id}' + + if host_dir is not None: + import shutil + + def _clear() -> bool: + shutil.rmtree(host_dir, ignore_errors=True) + survived = os.path.exists(host_dir) and bool(os.listdir(host_dir)) + os.makedirs(host_dir, exist_ok=True) + return survived + + survived = await asyncio.to_thread(_clear) + if not survived: + return + # Root-owned container files survived the host delete — fall through. + + try: + await self.execute_tool( + {'command': f'rm -rf {target_dir} && mkdir -p {target_dir}', 'timeout_sec': 30}, + query, + ) + except Exception as exc: + self.ap.logger.warning(f'Failed to clear sandbox outbox {target_dir}: {exc}') + + @staticmethod + def _classify_outbound_entries(entries: list[dict]) -> list[dict]: + """Classify outbox files into Image/Voice/File component descriptors.""" + image_exts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'} + voice_exts = {'wav', 'mp3', 'silk', 'amr', 'ogg', 'm4a', 'aac'} + mime_by_ext = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'bmp': 'image/bmp', + } + attachments: list[dict] = [] + for entry in entries or []: + name = str(entry.get('name', '') or '') + b64 = entry.get('b64') + if not name or not b64: + continue + ext = name.rsplit('.', 1)[-1].lower() if '.' in name else '' + base_name = os.path.basename(name) + if ext in image_exts: + mime = mime_by_ext.get(ext, 'image/png') + attachments.append({'type': 'Image', 'name': base_name, 'base64': f'data:{mime};base64,{b64}'}) + elif ext in voice_exts: + attachments.append({'type': 'Voice', 'name': base_name, 'base64': f'data:audio/{ext};base64,{b64}'}) + else: + attachments.append({'type': 'File', 'name': base_name, 'base64': b64}) + return attachments + + async def shutdown(self): + await self.client.shutdown() + + def dispose(self): + if self._runtime_connector is not None: + self._runtime_connector.dispose() + loop = getattr(self.ap, 'event_loop', None) + if loop is not None and not loop.is_closed() and (self._shutdown_task is None or self._shutdown_task.done()): + self._shutdown_task = loop.create_task(self.shutdown()) + + async def get_sessions(self) -> list[dict]: + if not self._available: + return [] + try: + return await self.client.get_sessions() + except Exception: + return [] + + def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = False) -> BoxSpec: + spec_payload = dict(spec_payload) + spec_payload.setdefault('env', {}) + if spec_payload.get('host_path') in (None, '') and self.default_workspace is not None: + spec_payload['host_path'] = self.default_workspace + if spec_payload.get('workspace_quota_mb') in (None, '') and self.workspace_quota_mb is not None: + spec_payload['workspace_quota_mb'] = self.workspace_quota_mb + + # Global custom image overrides profile default (but not caller-specified image) + if self.custom_image and 'image' not in spec_payload: + spec_payload['image'] = self.custom_image + + self._apply_profile(spec_payload) + + try: + spec = BoxSpec.model_validate(spec_payload) + except pydantic.ValidationError as exc: + first_error = exc.errors()[0] + raise BoxValidationError(first_error.get('msg', 'invalid box arguments')) from exc + + if not skip_host_mount_validation: + self._validate_host_mount(spec) + return spec + + async def create_session(self, spec_payload: dict, *, skip_host_mount_validation: bool = False) -> dict: + spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation) + return await self.client.create_session(spec) + + async def start_managed_process(self, session_id: str, process_payload: dict) -> BoxManagedProcessInfo: + process_spec = BoxManagedProcessSpec.model_validate(process_payload) + return await self.client.start_managed_process(session_id, process_spec) + + async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo: + return await self.client.get_managed_process(session_id, process_id) + + async def stop_managed_process(self, session_id: str, process_id: str = 'default') -> None: + return await self.client.stop_managed_process(session_id, process_id) + + def get_managed_process_websocket_url(self, session_id: str, process_id: str = 'default') -> str: + getter = getattr(self.client, 'get_managed_process_websocket_url', None) + if getter is None: + raise BoxValidationError('box runtime client does not support managed process websocket attach') + ws_relay_base_url = ( + self._runtime_connector.ws_relay_base_url + if self._runtime_connector is not None + else 'http://127.0.0.1:5410' + ) + return getter(session_id, ws_relay_base_url, process_id) + + async def list_skills(self) -> list[dict]: + return await self.client.list_skills() + + async def get_skill(self, name: str) -> dict | None: + return await self.client.get_skill(name) + + async def create_skill(self, skill: dict) -> dict: + return await self.client.create_skill(skill) + + async def update_skill(self, name: str, skill: dict) -> dict: + return await self.client.update_skill(name, skill) + + async def delete_skill(self, name: str) -> None: + await self.client.delete_skill(name) + + async def scan_skill_directory(self, path: str) -> dict: + return await self.client.scan_skill_directory(path) + + async def list_skill_files( + self, + name: str, + path: str = '.', + include_hidden: bool = False, + max_entries: int = 200, + ) -> dict: + return await self.client.list_skill_files(name, path, include_hidden, max_entries) + + async def read_skill_file(self, name: str, path: str) -> dict: + return await self.client.read_skill_file(name, path) + + async def write_skill_file(self, name: str, path: str, content: str) -> dict: + return await self.client.write_skill_file(name, path, content) + + async def preview_skill_zip( + self, + file_bytes: bytes, + filename: str, + source_subdir: str = '', + target_suffix: str = 'upload', + ) -> list[dict]: + return await self.client.preview_skill_zip(file_bytes, filename, source_subdir, target_suffix) + + async def install_skill_zip( + self, + file_bytes: bytes, + filename: str, + source_paths: list[str] | None = None, + source_path: str = '', + source_subdir: str = '', + target_suffix: str = 'upload', + ) -> list[dict]: + return await self.client.install_skill_zip( + file_bytes, + filename, + source_paths, + source_path, + source_subdir, + target_suffix, + ) + + def _serialize_result(self, result: BoxExecutionResult) -> dict: + stdout, stdout_truncated = self._truncate(result.stdout) + stderr, stderr_truncated = self._truncate(result.stderr) + + return { + 'session_id': result.session_id, + 'backend': result.backend_name, + 'status': result.status.value, + 'ok': result.ok, + 'exit_code': result.exit_code, + 'stdout': stdout, + 'stderr': stderr, + 'stdout_truncated': stdout_truncated, + 'stderr_truncated': stderr_truncated, + 'duration_ms': result.duration_ms, + } + + def _truncate(self, text: str) -> tuple[str, bool]: + if len(text) <= self.output_limit_chars: + return text, False + if self.output_limit_chars <= 0: + return '', True + + head_size = 0 + tail_size = 0 + notice = '' + # Recompute once the omitted count is known so the final payload + # stays within output_limit_chars even after adding the notice. + for _ in range(4): + omitted = max(len(text) - head_size - tail_size, 0) + notice = f'\n\n... [{omitted} characters truncated] ...\n\n' + available = self.output_limit_chars - len(notice) + if available <= 0: + return notice[: self.output_limit_chars], True + + new_head_size = int(available * 0.6) + new_tail_size = available - new_head_size + if new_head_size == head_size and new_tail_size == tail_size: + break + head_size = new_head_size + tail_size = new_tail_size + + head = text[:head_size] + tail = text[-tail_size:] if tail_size else '' + truncated = f'{head}{notice}{tail}' + return truncated[: self.output_limit_chars], True + + def _summarize_spec(self, spec: BoxSpec) -> dict: + cmd = spec.cmd.strip() + if len(cmd) > 400: + cmd = f'{cmd[:397]}...' + + return { + 'session_id': spec.session_id, + 'workdir': spec.workdir, + 'mount_path': spec.mount_path, + 'timeout_sec': spec.timeout_sec, + 'network': spec.network.value, + 'image': spec.image, + 'host_path': spec.host_path, + 'host_path_mode': spec.host_path_mode.value, + 'cpus': spec.cpus, + 'memory_mb': spec.memory_mb, + 'pids_limit': spec.pids_limit, + 'read_only_rootfs': spec.read_only_rootfs, + 'workspace_quota_mb': spec.workspace_quota_mb, + 'env_keys': sorted(spec.env.keys()), + 'cmd': cmd, + } + + def _summarize_result(self, result: BoxExecutionResult) -> dict: + stdout_preview = result.stdout[:200] + stderr_preview = result.stderr[:200] + if len(result.stdout) > 200: + stdout_preview = f'{stdout_preview}...' + if len(result.stderr) > 200: + stderr_preview = f'{stderr_preview}...' + + return { + 'session_id': result.session_id, + 'backend': result.backend_name, + 'status': result.status.value, + 'exit_code': result.exit_code, + 'duration_ms': result.duration_ms, + 'stdout_preview': stdout_preview, + 'stderr_preview': stderr_preview, + } + + def _local_config(self) -> dict: + """Return ``box.local`` from instance config. + + Environment overrides are applied uniformly by + ``LoadConfigStage._apply_env_overrides_to_config`` (e.g. + ``BOX__LOCAL__HOST_ROOT``) before this is read, so no box-specific + env parsing happens here. + """ + return dict(_get_box_config(self.ap).get('local') or {}) + + def _load_allowed_mount_roots(self) -> list[str]: + configured_roots = self._local_config().get('allowed_mount_roots', []) + # The unified env-override mechanism stores a brand-new key as a raw + # string when the key is absent from config.yaml. Accept a + # comma-separated string as well as a list so that + # ``BOX__LOCAL__ALLOWED_MOUNT_ROOTS="/a,/b"`` keeps working even when + # the config file has no ``box.local.allowed_mount_roots`` entry. + if isinstance(configured_roots, str): + configured_roots = [item.strip() for item in configured_roots.split(',') if item.strip()] + + normalized_roots: list[str] = [] + for root in configured_roots: + root_value = str(root).strip() + if not root_value: + continue + normalized_roots.append(os.path.realpath(os.path.abspath(root_value))) + + if not normalized_roots and self.host_root is not None: + normalized_roots.append(self.host_root) + + return normalized_roots + + def _load_host_root(self) -> str | None: + host_root = str(self._local_config().get('host_root', '')).strip() + if not host_root: + return None + return os.path.realpath(os.path.abspath(host_root)) + + def _load_default_workspace(self) -> str | None: + default_workspace = str(self._local_config().get('default_workspace', '')).strip() + if not default_workspace: + if self.host_root is None: + return None + default_workspace = os.path.join(self.host_root, 'default') + elif not os.path.isabs(default_workspace) and self.host_root is not None: + default_workspace = os.path.join(self.host_root, default_workspace) + return os.path.realpath(os.path.abspath(default_workspace)) + + def get_skills_root(self) -> str | None: + skills_root = str(self._local_config().get('skills_root', '') or 'skills').strip() + if not skills_root: + skills_root = 'skills' + if not os.path.isabs(skills_root) and self.host_root is not None: + skills_root = os.path.join(self.host_root, skills_root) + return os.path.realpath(os.path.abspath(skills_root)) + + def _load_enabled(self) -> bool: + """Read ``box.enabled`` (top-level, not ``box.local.*``). Default True + — disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings, + and the standard env-overridden truthy values that + ``LoadConfigStage._apply_env_overrides_to_config`` produces.""" + raw = _get_box_config(self.ap).get('enabled', True) + if isinstance(raw, bool): + return raw + return str(raw).strip().lower() not in ('false', '0', 'no', 'off', '') + + def _load_custom_image(self) -> str | None: + raw = str(self._local_config().get('image', '') or '').strip() + return raw or None + + def _forced_box_session_id_template(self) -> str: + """Return the SaaS-forced sandbox-scope template, or '' when unset. + + Read from ``system.limitation.force_box_session_id_template``. A + non-empty value pins every pipeline to a single sandbox scope + (e.g. ``'{global}'``) and cannot be overridden per-pipeline. + """ + limitation = ( + (self.ap.instance_config.data or {}).get('system', {}).get('limitation', {}) + if getattr(self.ap, 'instance_config', None) is not None + else {} + ) + return str(limitation.get('force_box_session_id_template', '') or '').strip() + + def _load_workspace_quota_mb(self) -> int | None: + raw_value = self._local_config().get('workspace_quota_mb') + if raw_value in (None, ''): + return None + try: + value = _INT_ADAPTER.validate_python(raw_value) + except pydantic.ValidationError as exc: + raise BoxValidationError('workspace_quota_mb must be an integer greater than or equal to 0') from exc + if value < 0: + raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0') + return value + + def _ensure_default_workspace(self): + if self.default_workspace is None: + return + + if not self.shares_filesystem_with_box: + return + + if os.path.isdir(self.default_workspace): + return + + if os.path.exists(self.default_workspace): + raise BoxValidationError('box.local.default_workspace must point to a directory on the host') + + if not self.allowed_mount_roots: + raise BoxValidationError( + 'box.local.default_workspace cannot be created because no allowed_mount_roots are configured' + ) + + for allowed_root in self.allowed_mount_roots: + if _is_path_under(self.default_workspace, allowed_root): + os.makedirs(self.default_workspace, exist_ok=True) + return + + allowed_roots = ', '.join(self.allowed_mount_roots) + raise BoxValidationError(f'box.local.default_workspace is outside allowed_mount_roots: {allowed_roots}') + + def _validate_host_mount(self, spec: BoxSpec): + if spec.host_path is None: + return + + host_path = os.path.realpath(spec.host_path) + if self.shares_filesystem_with_box and not os.path.isdir(host_path): + raise BoxValidationError('host_path must point to an existing directory on the host') + + if not self.allowed_mount_roots: + raise BoxValidationError('host_path mounting is disabled because no allowed_mount_roots are configured') + + for allowed_root in self.allowed_mount_roots: + if _is_path_under(host_path, allowed_root): + return + + allowed_roots = ', '.join(self.allowed_mount_roots) + raise BoxValidationError(f'host_path is outside allowed_mount_roots: {allowed_roots}') + + def _load_profile(self) -> BoxProfile: + profile_name = str(self._local_config().get('profile', 'default')).strip() or 'default' + + profile = BUILTIN_PROFILES.get(profile_name) + if profile is None: + available = ', '.join(sorted(BUILTIN_PROFILES)) + raise BoxValidationError(f"unknown box profile '{profile_name}', available profiles: {available}") + return profile + + def _apply_profile(self, params: dict): + """Merge profile defaults into *params* in-place, enforce locked fields and clamp timeout.""" + profile = self.profile + _PROFILE_FIELDS = ( + 'image', + 'network', + 'timeout_sec', + 'host_path_mode', + 'cpus', + 'memory_mb', + 'pids_limit', + 'read_only_rootfs', + 'workspace_quota_mb', + ) + + for field in _PROFILE_FIELDS: + profile_value = getattr(profile, field) + raw_value = profile_value.value if isinstance(profile_value, enum.Enum) else profile_value + + if field in profile.locked: + params[field] = raw_value + elif field not in params: + params[field] = raw_value + + timeout = params.get('timeout_sec') + try: + normalized_timeout = _INT_ADAPTER.validate_python(timeout) + except pydantic.ValidationError: + return + + if normalized_timeout > profile.max_timeout_sec: + params['timeout_sec'] = profile.max_timeout_sec + + def _get_workspace_size_bytes(self, root: str) -> int: + total = 0 + + def _walk(path: str): + nonlocal total + try: + with os.scandir(path) as entries: + for entry in entries: + try: + if entry.is_symlink(): + total += entry.stat(follow_symlinks=False).st_size + continue + if entry.is_dir(follow_symlinks=False): + _walk(entry.path) + continue + total += entry.stat(follow_symlinks=False).st_size + except FileNotFoundError: + continue + except FileNotFoundError: + return + + _walk(root) + return total + + async def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None: + if spec.host_path is None or spec.workspace_quota_mb <= 0: + return + + host_path = os.path.realpath(spec.host_path) + if not os.path.isdir(host_path): + return + + # Walk the workspace off the event loop — this runs on every + # quota-enforced exec, and a large tree would otherwise block the whole + # asyncio runtime (all bots/pipelines) for the duration of the scan. + used_bytes = await asyncio.to_thread(self._get_workspace_size_bytes, host_path) + limit_bytes = spec.workspace_quota_mb * _MIB + if used_bytes <= limit_bytes: + return + + raise BoxValidationError( + f'workspace quota exceeded {phase}: ' + f'used={used_bytes} bytes limit={limit_bytes} bytes ' + f'host_path={host_path} session_id={spec.session_id}' + ) + + async def _cleanup_exceeded_session(self, spec: BoxSpec) -> None: + try: + await self.client.delete_session(spec.session_id) + except Exception as exc: + self.ap.logger.warning( + 'Failed to clean up Box session after workspace quota was exceeded: ' + f'session_id={spec.session_id} error={exc}' + ) + + # ── Observability ───────────────────────────────────────────────── + + def _record_error(self, exc: Exception, query: pipeline_query.Query): + telemetry_features.increment(query, 'sandbox', 'errors') + self._recent_errors.append( + { + 'timestamp': _dt.datetime.now(_UTC).isoformat(), + 'type': type(exc).__name__, + 'message': str(exc), + 'query_id': str(query.query_id), + } + ) + + def get_recent_errors(self) -> list[dict]: + return list(self._recent_errors) + + def get_system_guidance(self, query_id=None) -> str: + """Return LLM system-prompt guidance for the exec tool. + + All execution-specific prompt text is kept here so that callers + (e.g. LocalAgentRunner) stay free of box domain knowledge. + + ``query_id`` is the current turn's pipeline query id. When provided, + the guidance ALWAYS advertises the per-query outbox path so the agent + knows how to deliver generated files back to the user — even on turns + where the user sent no inbound attachment (e.g. "generate a QR code"), + which is exactly when the inbound-attachment note never fires. Outbound + collection in the wrapper runs on every turn regardless of inbound + files, so without this the file would be produced and silently dropped. + """ + guidance = ( + 'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, ' + 'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, ' + 'JSON, or other data and asks for a computed answer, prefer running a short Python script via exec ' + 'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation ' + 'details, do not include the generated script in the final answer; return the result and a brief explanation only.' + ) + if self.default_workspace: + guidance += ( + ' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or ' + 'modify local files in the working directory, use exec with /workspace paths directly; do not ask the ' + 'user for directory parameters unless they explicitly need a different directory.' + ) + if query_id is not None: + outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_id}' + guidance += ( + f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, ' + f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be ' + 'delivered to the user automatically; do not paste file contents or base64 into your reply.' + ) + return guidance + + async def get_status(self) -> dict: + if not self._available: + return { + 'available': False, + 'enabled': self._enabled, + 'profile': self.profile.name, + 'recent_error_count': len(self._recent_errors), + 'connector_error': self._connector_error, + } + try: + runtime_status = await self.client.get_status() + except Exception as exc: + # RPC failed — the runtime likely just disconnected and the + # heartbeat hasn't flipped _available yet. + return { + 'available': False, + 'enabled': self._enabled, + 'profile': self.profile.name, + 'recent_error_count': len(self._recent_errors), + 'connector_error': str(exc), + } + # Backend state can be unavailable even when the connector is healthy + # (operator selected nsjail but the binary is missing, Docker daemon + # went down after the runtime started, E2B credentials wrong, ...). + # Report the combined state in the top-level ``available`` so the + # frontend banner / ``useBoxStatus`` hook / native-tool gate all + # agree on "actually usable" rather than "connector alive". The + # detailed ``backend`` object stays in the payload so the dialog + # can still show which backend was tried. + backend_info = runtime_status.get('backend') if isinstance(runtime_status, dict) else None + backend_ok = bool(backend_info and backend_info.get('available', False)) + payload = { + **runtime_status, + 'available': backend_ok, + 'enabled': self._enabled, + 'profile': self.profile.name, + 'recent_error_count': len(self._recent_errors), + } + if not backend_ok and 'connector_error' not in payload: + backend_name = backend_info.get('name') if backend_info else None + if backend_name: + payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable' + else: + payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available' + return payload diff --git a/src/langbot/pkg/box/workspace.py b/src/langbot/pkg/box/workspace.py new file mode 100644 index 0000000..26d1a41 --- /dev/null +++ b/src/langbot/pkg/box/workspace.py @@ -0,0 +1,430 @@ +"""Reusable workspace/session helpers built on top of Box. + +This module is the middle layer between the raw Box runtime primitives and +application-specific flows such as skills or MCP stdio. + +It intentionally stays generic: +- path and virtualenv rewriting are workspace concerns +- Python project detection/bootstrap are workspace concerns +- session exec / managed-process helpers are workspace concerns + +Higher layers add their own semantics on top, for example: +- skills choose a stable per-skill session id and use repeated exec +- MCP stdio chooses how to prepare dependencies and attaches to a managed process +""" + +from __future__ import annotations + +import os +import textwrap +from typing import Any + +PYTHON_MANIFEST_FILES = ( + 'requirements.txt', + 'pyproject.toml', + 'setup.py', + 'setup.cfg', +) +_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'}) +_VENV_BIN_DIRS = frozenset({'bin', 'Scripts'}) + + +def normalize_host_path(path: str | None) -> str: + if path is None: + return '' + stripped = str(path).strip() + if not stripped: + return '' + return os.path.realpath(os.path.abspath(stripped)) + + +def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = '/workspace') -> str: + """Translate a host path into the path visible inside the sandbox mount.""" + if not host_path or not path: + return path + normalized_host = os.path.realpath(host_path) + normalized_path = os.path.realpath(path) + if normalized_path.startswith(normalized_host + '/'): + return mount_path + normalized_path[len(normalized_host) :] + if normalized_path == normalized_host: + return mount_path + return path + + +def unwrap_venv_path(directory: str) -> str: + """Collapse ``.../.venv/bin`` style paths back to the project root.""" + parts = directory.replace('\\', '/').split('/') + for i in range(len(parts) - 1, 0, -1): + if parts[i] in _VENV_BIN_DIRS and i >= 1: + venv_dir = parts[i - 1] + if venv_dir in _VENV_DIRS: + project_root = '/'.join(parts[: i - 1]) + return project_root if project_root else '/' + return directory + + +def infer_workspace_host_path(command: str, args: list[str] | None = None) -> str | None: + """Infer the project/workspace root from absolute command/arg paths.""" + candidates: list[str] = [] + for part in [command, *(args or [])]: + if not os.path.isabs(part): + continue + if os.path.exists(part): + directory = os.path.dirname(part) + candidates.append(os.path.realpath(unwrap_venv_path(directory))) + if not candidates: + return None + common = os.path.commonpath(candidates) + return common if common != '/' else None + + +def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str = '/workspace') -> str: + """Rewrite host venv interpreters to plain ``python`` inside the sandbox. + + Once a project is mounted into the sandbox, host virtualenv paths are no + longer valid. For those paths we intentionally drop down to ``python`` and + let the sandbox-side environment/bootstrap decide what interpreter to use. + """ + if not host_path or not command: + return command + normalized_host = os.path.realpath(host_path) + normalized_command = os.path.realpath(command) + if not normalized_command.startswith(normalized_host + '/'): + return command + rel = normalized_command[len(normalized_host) + 1 :] + parts = rel.replace('\\', '/').split('/') + if len(parts) >= 3 and parts[0] in _VENV_DIRS and parts[1] in _VENV_BIN_DIRS and parts[2].startswith('python'): + return 'python' + return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path) + + +def list_python_manifest_files(host_path: str | None) -> list[str]: + normalized_root = normalize_host_path(host_path) + if not normalized_root: + return [] + return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))] + + +def classify_python_workspace(host_path: str | None) -> str | None: + """Return the generic Python workspace shape, without app-specific policy.""" + manifest_files = set(list_python_manifest_files(host_path)) + if not manifest_files: + return None + if {'pyproject.toml', 'setup.py', 'setup.cfg'} & manifest_files: + return 'package' + if 'requirements.txt' in manifest_files: + return 'requirements' + return None + + +def should_prepare_python_env(host_path: str | None) -> bool: + normalized_root = normalize_host_path(host_path) + if not normalized_root: + return False + if os.path.isdir(os.path.join(normalized_root, '.venv')): + return True + return bool(list_python_manifest_files(normalized_root)) + + +def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace') -> str: + """Wrap a command with a reusable sandbox-local Python env bootstrap. + + This is the generic "workspace is a Python project" path used by mutable + workspaces such as skills. Read-only installation strategies stay in the + higher-level caller because they are application policy, not workspace + semantics. + """ + bootstrap = textwrap.dedent( + f""" + set -e + + _LB_VENV_DIR="{mount_path}/.venv" + _LB_META_DIR="{mount_path}/.langbot" + _LB_META_FILE="$_LB_META_DIR/python-env.json" + _LB_LOCK_DIR="$_LB_META_DIR/python-env.lock" + _LB_TMP_DIR="{mount_path}/.tmp" + _LB_PIP_CACHE_DIR="{mount_path}/.cache/pip" + + mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR" + _LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)" + if [ -z "$_LB_SYSTEM_PYTHON" ]; then + echo "python3 or python is required to prepare the workspace Python environment" >&2 + exit 127 + fi + + export TMPDIR="$_LB_TMP_DIR" + export TEMP="$_LB_TMP_DIR" + export TMP="$_LB_TMP_DIR" + export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR" + + _lb_python_meta() {{ + "$_LB_SYSTEM_PYTHON" - <<'PY' + import hashlib + import json + import os + import sys + + root = "{mount_path}" + digest = hashlib.sha256() + manifest_files = [] + for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"): + path = os.path.join(root, rel) + if not os.path.isfile(path): + continue + manifest_files.append(rel) + with open(path, "rb") as handle: + digest.update(rel.encode("utf-8")) + digest.update(b"\\0") + digest.update(handle.read()) + digest.update(b"\\0") + + print( + json.dumps( + {{ + "python_executable": sys.executable, + "python_version": list(sys.version_info[:3]), + "manifest_files": manifest_files, + "manifest_sha256": digest.hexdigest(), + }}, + sort_keys=True, + ) + ) + PY + }} + + _LB_CURRENT_META="$(_lb_python_meta)" + _LB_NEEDS_BOOTSTRAP=0 + + if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ ! -f "$_LB_META_FILE" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then + _LB_NEEDS_BOOTSTRAP=1 + fi + + if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then + _LB_LOCK_WAIT=0 + while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do + if [ "$_LB_LOCK_WAIT" -ge 120 ]; then + _LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)" + if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then + echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2 + exit 1 + fi + echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2 + rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true + if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then + break + fi + echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2 + exit 1 + fi + sleep 1 + _LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1)) + done + printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true + + _lb_cleanup_lock() {{ + rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true + }} + trap _lb_cleanup_lock EXIT INT TERM + + _LB_CURRENT_META="$(_lb_python_meta)" + _LB_NEEDS_BOOTSTRAP=0 + if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ ! -f "$_LB_META_FILE" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then + _LB_NEEDS_BOOTSTRAP=1 + fi + + if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then + rm -rf "$_LB_VENV_DIR" + "$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR" + . "$_LB_VENV_DIR/bin/activate" + python -m pip install --upgrade pip setuptools wheel + if [ -f "{mount_path}/requirements.txt" ]; then + python -m pip install -r "{mount_path}/requirements.txt" + elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then + python -m pip install "{mount_path}" + fi + printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE" + fi + fi + + export VIRTUAL_ENV="$_LB_VENV_DIR" + export PATH="$_LB_VENV_DIR/bin:$PATH" + {command} + """ + ).strip() + return bootstrap + '\n' + + +class BoxWorkspaceSession: + """High-level handle for one reusable workspace-backed Box session. + + The Box runtime already understands sessions and managed processes. This + wrapper adds LangBot's workspace-centric view on top: a mounted host path, + a stable ``session_id``, optional environment defaults, and convenience + helpers for exec or long-running processes inside that workspace. + """ + + def __init__( + self, + box_service, + session_id: str, + *, + host_path: str | None = None, + host_path_mode: str = 'rw', + workdir: str = '/workspace', + env: dict[str, str] | None = None, + mount_path: str = '/workspace', + network: str | None = None, + read_only_rootfs: bool | None = None, + image: str | None = None, + cpus: float | None = None, + memory_mb: int | None = None, + pids_limit: int | None = None, + persistent: bool = False, + ): + self.box_service = box_service + self.session_id = session_id + self.host_path = host_path + self.host_path_mode = host_path_mode + self.workdir = workdir + self.env = dict(env or {}) + self.mount_path = mount_path + self.network = network + self.read_only_rootfs = read_only_rootfs + self.image = image + self.cpus = cpus + self.memory_mb = memory_mb + self.pids_limit = pids_limit + self.persistent = persistent + + def rewrite_path(self, path: str) -> str: + return rewrite_mounted_path(path, self.host_path, mount_path=self.mount_path) + + def rewrite_venv_command(self, command: str) -> str: + return rewrite_venv_command(command, self.host_path, mount_path=self.mount_path) + + def build_session_payload(self) -> dict[str, Any]: + # Keep this payload generic so callers can reuse the same workspace + # handle for plain exec, file-producing tasks, or managed processes. + payload: dict[str, Any] = { + 'session_id': self.session_id, + 'workdir': self.workdir, + 'env': self.env, + 'persistent': self.persistent, + } + if self.network is not None: + payload['network'] = self.network + if self.read_only_rootfs is not None: + payload['read_only_rootfs'] = self.read_only_rootfs + if self.host_path: + payload['host_path'] = self.host_path + payload['host_path_mode'] = self.host_path_mode + for key in ('image', 'cpus', 'memory_mb', 'pids_limit'): + value = getattr(self, key) + if value is not None: + payload[key] = value + return payload + + def build_exec_payload( + self, + cmd: str, + *, + workdir: str | None = None, + env: dict[str, str] | None = None, + timeout_sec: int | None = None, + ) -> dict[str, Any]: + # Exec payloads inherit the session-level workspace config, then layer + # per-call command/workdir/env overrides on top. + payload = self.build_session_payload() + payload['cmd'] = cmd + payload['workdir'] = workdir or self.workdir + if timeout_sec is not None: + payload['timeout_sec'] = timeout_sec + resolved_env = self.env if env is None else env + if resolved_env: + payload['env'] = resolved_env + elif 'env' in payload and not payload['env']: + payload.pop('env') + return payload + + async def execute_raw( + self, + cmd: str, + *, + workdir: str | None = None, + env: dict[str, str] | None = None, + timeout_sec: int | None = None, + ): + payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec) + return await self.box_service.client.execute(self.box_service.build_spec(payload)) + + async def execute_for_query( + self, + query, + cmd: str, + *, + workdir: str | None = None, + env: dict[str, str] | None = None, + timeout_sec: int | None = None, + ) -> dict: + payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec) + return await self.box_service.execute_spec_payload(payload, query) + + async def create_session(self): + return await self.box_service.create_session(self.build_session_payload()) + + def build_process_payload( + self, + command: str, + args: list[str] | None = None, + *, + env: dict[str, str] | None = None, + cwd: str = '/workspace', + ) -> dict[str, Any]: + # Managed processes run inside the same workspace model as one-shot + # execs, so path/venv rewriting is shared here. + normalized_command = command + normalized_args = list(args or []) + normalized_cwd = cwd + if self.host_path: + normalized_command = self.rewrite_venv_command(command) + normalized_args = [self.rewrite_path(arg) for arg in normalized_args] + normalized_cwd = self.rewrite_path(cwd) + return { + 'command': normalized_command, + 'args': normalized_args, + 'env': dict(env or {}), + 'cwd': normalized_cwd, + } + + async def start_managed_process( + self, + command: str, + args: list[str] | None = None, + *, + process_id: str = 'default', + env: dict[str, str] | None = None, + cwd: str = '/workspace', + ): + payload = self.build_process_payload(command, args, env=env, cwd=cwd) + payload['process_id'] = process_id + return await self.box_service.start_managed_process(self.session_id, payload) + + async def get_managed_process(self, process_id: str = 'default'): + return await self.box_service.get_managed_process(self.session_id, process_id) + + async def stop_managed_process(self, process_id: str = 'default') -> None: + await self.box_service.stop_managed_process(self.session_id, process_id) + + def get_managed_process_websocket_url(self, process_id: str = 'default') -> str: + return self.box_service.get_managed_process_websocket_url(self.session_id, process_id) + + async def cleanup(self) -> None: + await self.box_service.client.delete_session(self.session_id) diff --git a/src/langbot/pkg/command/__init__.py b/src/langbot/pkg/command/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/command/cmdmgr.py b/src/langbot/pkg/command/cmdmgr.py new file mode 100644 index 0000000..ee064c2 --- /dev/null +++ b/src/langbot/pkg/command/cmdmgr.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import typing + +from ..core import app +from . import operator +from ..utils import importutil +import langbot_plugin.api.entities.builtin.provider.session as provider_session +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors + +# 引入所有算子以便注册 +from . import operators + +importutil.import_modules_in_pkg(operators) + + +class CommandManager: + ap: app.Application + + cmd_list: list[operator.CommandOperator] + """ + Runtime command list, flat storage, each object contains a reference to the corresponding child node + """ + + def __init__(self, ap: app.Application): + self.ap = ap + + async def initialize(self): + # 设置各个类的路径 + def set_path(cls: operator.CommandOperator, ancestors: list[str]): + cls.path = '.'.join(ancestors + [cls.name]) + for op in operator.preregistered_operators: + if op.parent_class == cls: + set_path(op, ancestors + [cls.name]) + + for cls in operator.preregistered_operators: + if cls.parent_class is None: + set_path(cls, []) + + # 应用命令权限配置 + # for cls in operator.preregistered_operators: + # if cls.path in self.ap.instance_config.data['command']['privilege']: + # cls.lowest_privilege = self.ap.instance_config.data['command']['privilege'][cls.path] + + # 实例化所有类 + self.cmd_list = [cls(self.ap) for cls in operator.preregistered_operators] + + # 设置所有类的子节点 + for cmd in self.cmd_list: + cmd.children = [child for child in self.cmd_list if child.parent_class == cmd.__class__] + + # 初始化所有类 + for cmd in self.cmd_list: + await cmd.initialize() + + async def _execute( + self, + context: command_context.ExecuteContext, + operator_list: list[operator.CommandOperator], + operator: operator.CommandOperator = None, + bound_plugins: list[str] | None = None, + ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: + """执行命令""" + + command_list = await self.ap.plugin_connector.list_commands(bound_plugins) + + for command in command_list: + if command.metadata.name == context.command: + async for ret in self.ap.plugin_connector.execute_command(context, bound_plugins): + yield ret + break + else: + yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(context.command)) + + async def execute( + self, + command_text: str, + full_command_text: str, + query: pipeline_query.Query, + session: provider_session.Session, + ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: + """执行命令""" + + privilege = 1 + + import sqlalchemy as _sa + from ..entity.persistence.bot import BotAdmin as _BotAdmin + + _admins = await self.ap.persistence_mgr.execute_async( + _sa.select(_BotAdmin).where( + _BotAdmin.bot_uuid == (query.bot_uuid or ''), + _BotAdmin.launcher_type == query.launcher_type.value, + _BotAdmin.launcher_id == str(query.launcher_id), + ) + ) + if _admins.first() is not None: + privilege = 2 + + ctx = command_context.ExecuteContext( + query_id=query.query_id, + session=session, + command_text=command_text, + full_command_text=full_command_text, + command='', + crt_command='', + params=command_text.split(' '), + crt_params=command_text.split(' '), + privilege=privilege, + ) + + ctx.command = ctx.params[0] + + ctx.shift() + + # Get bound plugins from query + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + + async for ret in self._execute(ctx, self.cmd_list, bound_plugins=bound_plugins): + yield ret diff --git a/src/langbot/pkg/command/operator.py b/src/langbot/pkg/command/operator.py new file mode 100644 index 0000000..0157cf2 --- /dev/null +++ b/src/langbot/pkg/command/operator.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import typing +import abc + +from ..core import app +from langbot_plugin.api.entities.builtin.command import context as command_context + + +preregistered_operators: list[typing.Type[CommandOperator]] = [] +"""预注册命令算子列表。在初始化时,所有算子类会被注册到此列表中。""" + + +def operator_class( + name: str, + help: str = '', + usage: str = None, + alias: list[str] = [], + privilege: int = 1, # 1为普通用户,2为管理员 + parent_class: typing.Type[CommandOperator] = None, +) -> typing.Callable[[typing.Type[CommandOperator]], typing.Type[CommandOperator]]: + """命令类装饰器 + + Args: + name (str): 名称 + help (str, optional): 帮助信息. Defaults to "". + usage (str, optional): 使用说明. Defaults to None. + alias (list[str], optional): 别名. Defaults to []. + privilege (int, optional): 权限,1为普通用户可用,2为仅管理员可用. Defaults to 1. + parent_class (typing.Type[CommandOperator], optional): 父节点,若为None则为顶级命令. Defaults to None. + + Returns: + typing.Callable[[typing.Type[CommandOperator]], typing.Type[CommandOperator]]: 装饰器 + """ + + def decorator(cls: typing.Type[CommandOperator]) -> typing.Type[CommandOperator]: + assert issubclass(cls, CommandOperator) + + cls.name = name + cls.alias = alias + cls.help = help + cls.usage = usage + cls.parent_class = parent_class + cls.lowest_privilege = privilege + + preregistered_operators.append(cls) + + return cls + + return decorator + + +class CommandOperator(metaclass=abc.ABCMeta): + """命令算子抽象类 + + 以下的参数均不需要在子类中设置,只需要在使用装饰器注册类时作为参数传递即可。 + 命令支持级联,即一个命令可以有多个子命令,子命令可以有子命令,以此类推。 + 处理命令时,若有子命令,会以当前参数列表的第一个参数去匹配子命令,若匹配成功,则转移到子命令中执行。 + 若没有匹配成功或没有子命令,则执行当前命令。 + """ + + ap: app.Application + + name: str + """名称,搜索到时若符合则使用""" + + path: str + """路径,所有父节点的name的连接,用于定义命令权限,由管理器在初始化时自动设置。 + """ + + alias: list[str] + """同name""" + + help: str + """此节点的帮助信息""" + + usage: str = None + """用法""" + + parent_class: typing.Union[typing.Type[CommandOperator], None] = None + """父节点类。标记以供管理器在初始化时编织父子关系。""" + + lowest_privilege: int = 0 + """最低权限。若权限低于此值,则不予执行。""" + + children: list[CommandOperator] + """子节点。解析命令时,若节点有子节点,则以下一个参数去匹配子节点, + 若有匹配中的,转移到子节点中执行,若没有匹配中的或没有子节点,执行此节点。""" + + def __init__(self, ap: app.Application): + self.ap = ap + self.children = [] + + async def initialize(self): + pass + + @abc.abstractmethod + async def execute( + self, context: command_context.ExecuteContext + ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: + """实现此方法以执行命令 + + 支持多次yield以返回多个结果。 + 例如:一个安装插件的命令,可能会有下载、解压、安装等多个步骤,每个步骤都可以返回一个结果。 + + Args: + context (command_context.ExecuteContext): 命令执行上下文 + + Yields: + command_context.CommandReturn: 命令返回封装 + """ + pass diff --git a/src/langbot/pkg/command/operators/__init__.py b/src/langbot/pkg/command/operators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/command/operators/delc.py b/src/langbot/pkg/command/operators/delc.py new file mode 100644 index 0000000..86df481 --- /dev/null +++ b/src/langbot/pkg/command/operators/delc.py @@ -0,0 +1,48 @@ +# from __future__ import annotations + +# import typing + +# from .. import operator +# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors + + +# @operator.operator_class(name='del', help='删除当前会话的历史记录', usage='!del <序号>\n!del all') +# class DelOperator(operator.CommandOperator): +# async def execute( +# self, context: command_context.ExecuteContext +# ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: +# if context.session.conversations: +# delete_index = 0 +# if len(context.crt_params) > 0: +# try: +# delete_index = int(context.crt_params[0]) +# except Exception: +# yield command_context.CommandReturn(error=command_errors.CommandOperationError('索引必须是整数')) +# return + +# if delete_index < 0 or delete_index >= len(context.session.conversations): +# yield command_context.CommandReturn(error=command_errors.CommandOperationError('索引超出范围')) +# return + +# # 倒序 +# to_delete_index = len(context.session.conversations) - 1 - delete_index + +# if context.session.conversations[to_delete_index] == context.session.using_conversation: +# context.session.using_conversation = None + +# del context.session.conversations[to_delete_index] + +# yield command_context.CommandReturn(text=f'已删除对话: {delete_index}') +# else: +# yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话')) + + +# @operator.operator_class(name='all', help='删除此会话的所有历史记录', parent_class=DelOperator) +# class DelAllOperator(operator.CommandOperator): +# async def execute( +# self, context: command_context.ExecuteContext +# ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: +# context.session.conversations = [] +# context.session.using_conversation = None + +# yield command_context.CommandReturn(text='已删除所有对话') diff --git a/src/langbot/pkg/command/operators/last.py b/src/langbot/pkg/command/operators/last.py new file mode 100644 index 0000000..376beca --- /dev/null +++ b/src/langbot/pkg/command/operators/last.py @@ -0,0 +1,33 @@ +# from __future__ import annotations + +# import typing + + +# from .. import operator +# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors + + +# @operator.operator_class(name='last', help='切换到前一个对话', usage='!last') +# class LastOperator(operator.CommandOperator): +# async def execute( +# self, context: command_context.ExecuteContext +# ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: +# if context.session.conversations: +# # 找到当前会话的上一个会话 +# for index in range(len(context.session.conversations) - 1, -1, -1): +# if context.session.conversations[index] == context.session.using_conversation: +# if index == 0: +# yield command_context.CommandReturn( +# error=command_errors.CommandOperationError('已经是第一个对话了') +# ) +# return +# else: +# context.session.using_conversation = context.session.conversations[index - 1] +# time_str = context.session.using_conversation.create_time.strftime('%Y-%m-%d %H:%M:%S') + +# yield command_context.CommandReturn( +# text=f'已切换到上一个对话: {index} {time_str}: {context.session.using_conversation.messages[0].readable_str()}' +# ) +# return +# else: +# yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话')) diff --git a/src/langbot/pkg/command/operators/list.py b/src/langbot/pkg/command/operators/list.py new file mode 100644 index 0000000..1c8c052 --- /dev/null +++ b/src/langbot/pkg/command/operators/list.py @@ -0,0 +1,51 @@ +# from __future__ import annotations + +# import typing + +# from .. import operator +# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors + + +# @operator.operator_class(name='list', help='列出此会话中的所有历史对话', usage='!list\n!list <页码>') +# class ListOperator(operator.CommandOperator): +# async def execute( +# self, context: command_context.ExecuteContext +# ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: +# page = 0 + +# if len(context.crt_params) > 0: +# try: +# page = int(context.crt_params[0] - 1) +# except Exception: +# yield command_context.CommandReturn(error=command_errors.CommandOperationError('页码应为整数')) +# return + +# record_per_page = 10 + +# content = '' + +# index = 0 + +# using_conv_index = 0 + +# for conv in context.session.conversations[::-1]: +# time_str = conv.create_time.strftime('%Y-%m-%d %H:%M:%S') + +# if conv == context.session.using_conversation: +# using_conv_index = index + +# if index >= page * record_per_page and index < (page + 1) * record_per_page: +# content += ( +# f'{index} {time_str}: {conv.messages[0].readable_str() if len(conv.messages) > 0 else "无内容"}\n' +# ) +# index += 1 + +# if content == '': +# content = '无' +# else: +# if context.session.using_conversation is None: +# content += '\n当前处于新会话' +# else: +# content += f'\n当前会话: {using_conv_index} {context.session.using_conversation.create_time.strftime("%Y-%m-%d %H:%M:%S")}: {context.session.using_conversation.messages[0].readable_str() if len(context.session.using_conversation.messages) > 0 else "无内容"}' + +# yield command_context.CommandReturn(text=f'第 {page + 1} 页 (时间倒序):\n{content}') diff --git a/src/langbot/pkg/command/operators/next.py b/src/langbot/pkg/command/operators/next.py new file mode 100644 index 0000000..03bce4a --- /dev/null +++ b/src/langbot/pkg/command/operators/next.py @@ -0,0 +1,32 @@ +# from __future__ import annotations + +# import typing + +# from .. import operator +# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors + + +# @operator.operator_class(name='next', help='切换到后一个对话', usage='!next') +# class NextOperator(operator.CommandOperator): +# async def execute( +# self, context: command_context.ExecuteContext +# ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: +# if context.session.conversations: +# # 找到当前会话的下一个会话 +# for index in range(len(context.session.conversations)): +# if context.session.conversations[index] == context.session.using_conversation: +# if index == len(context.session.conversations) - 1: +# yield command_context.CommandReturn( +# error=command_errors.CommandOperationError('已经是最后一个对话了') +# ) +# return +# else: +# context.session.using_conversation = context.session.conversations[index + 1] +# time_str = context.session.using_conversation.create_time.strftime('%Y-%m-%d %H:%M:%S') + +# yield command_context.CommandReturn( +# text=f'已切换到后一个对话: {index} {time_str}: {context.session.using_conversation.messages[0].content}' +# ) +# return +# else: +# yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话')) diff --git a/src/langbot/pkg/command/operators/prompt.py b/src/langbot/pkg/command/operators/prompt.py new file mode 100644 index 0000000..13eec9b --- /dev/null +++ b/src/langbot/pkg/command/operators/prompt.py @@ -0,0 +1,23 @@ +# from __future__ import annotations + +# import typing + +# from .. import operator +# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors + + +# @operator.operator_class(name='prompt', help='查看当前对话的前文', usage='!prompt') +# class PromptOperator(operator.CommandOperator): +# async def execute( +# self, context: command_context.ExecuteContext +# ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: +# """执行""" +# if context.session.using_conversation is None: +# yield command_context.CommandReturn(error=command_errors.CommandOperationError('当前没有对话')) +# else: +# reply_str = '当前对话所有内容:\n\n' + +# for msg in context.session.using_conversation.messages: +# reply_str += f'{msg.role}: {msg.content}\n' + +# yield command_context.CommandReturn(text=reply_str) diff --git a/src/langbot/pkg/command/operators/resend.py b/src/langbot/pkg/command/operators/resend.py new file mode 100644 index 0000000..56d5416 --- /dev/null +++ b/src/langbot/pkg/command/operators/resend.py @@ -0,0 +1,29 @@ +# from __future__ import annotations + +# import typing + +# from .. import operator +# from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors + + +# @operator.operator_class(name='resend', help='重发当前会话的最后一条消息', usage='!resend') +# class ResendOperator(operator.CommandOperator): +# async def execute( +# self, context: command_context.ExecuteContext +# ) -> typing.AsyncGenerator[command_context.CommandReturn, None]: +# # 回滚到最后一条用户message前 +# if context.session.using_conversation is None: +# yield command_context.CommandReturn(error=command_errors.CommandError('当前没有对话')) +# else: +# conv_msg = context.session.using_conversation.messages + +# # 倒序一直删到最后一条用户message +# while len(conv_msg) > 0 and conv_msg[-1].role != 'user': +# conv_msg.pop() + +# if len(conv_msg) > 0: +# # 删除最后一条用户message +# conv_msg.pop() + +# # 不重发了,提示用户已删除就行了 +# yield command_context.CommandReturn(text='已删除最后一次请求记录') diff --git a/src/langbot/pkg/config/__init__.py b/src/langbot/pkg/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/config/impls/__init__.py b/src/langbot/pkg/config/impls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/config/impls/json.py b/src/langbot/pkg/config/impls/json.py new file mode 100644 index 0000000..0068341 --- /dev/null +++ b/src/langbot/pkg/config/impls/json.py @@ -0,0 +1,71 @@ +import os +import json +import importlib.resources as resources + +from langbot.pkg.config import model as file_model + + +class JSONConfigFile(file_model.ConfigFile): + """JSON config file""" + + def __init__( + self, + config_file_name: str, + template_resource_name: str = None, + template_data: dict = None, + ) -> None: + self.config_file_name = config_file_name + self.template_resource_name = template_resource_name + self.template_data = template_data + + def exists(self) -> bool: + return os.path.exists(self.config_file_name) + + async def get_template_file_str(self) -> str: + if self.template_resource_name is None: + return None + + with ( + resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f + ): + return f.read() + + async def create(self): + if await self.get_template_file_str() is not None: + with open(self.config_file_name, 'w', encoding='utf-8') as f: + f.write(await self.get_template_file_str()) + elif self.template_data is not None: + with open(self.config_file_name, 'w', encoding='utf-8') as f: + json.dump(self.template_data, f, indent=4, ensure_ascii=False) + else: + raise ValueError('template_file_name or template_data must be provided') + + async def load(self, completion: bool = True) -> dict: + if not self.exists(): + await self.create() + + template_file_str = await self.get_template_file_str() + + if template_file_str is not None: + self.template_data = json.loads(template_file_str) + + with open(self.config_file_name, 'r', encoding='utf-8') as f: + try: + cfg = json.load(f) + except json.JSONDecodeError as e: + raise Exception(f'Syntax error in config file {self.config_file_name}: {e}') + + if completion: + for key in self.template_data: + if key not in cfg: + cfg[key] = self.template_data[key] + + return cfg + + async def save(self, cfg: dict): + with open(self.config_file_name, 'w', encoding='utf-8') as f: + json.dump(cfg, f, indent=4, ensure_ascii=False) + + def save_sync(self, cfg: dict): + with open(self.config_file_name, 'w', encoding='utf-8') as f: + json.dump(cfg, f, indent=4, ensure_ascii=False) diff --git a/src/langbot/pkg/config/impls/pymodule.py b/src/langbot/pkg/config/impls/pymodule.py new file mode 100644 index 0000000..c3d04bc --- /dev/null +++ b/src/langbot/pkg/config/impls/pymodule.py @@ -0,0 +1,66 @@ +import os +import shutil +import importlib +import logging + +from .. import model as file_model + + +class PythonModuleConfigFile(file_model.ConfigFile): + """Python module config file""" + + config_file_name: str = None + """Config file name""" + + template_file_name: str = None + """Template file name""" + + def __init__(self, config_file_name: str, template_file_name: str) -> None: + self.config_file_name = config_file_name + self.template_file_name = template_file_name + + def exists(self) -> bool: + return os.path.exists(self.config_file_name) + + async def create(self): + shutil.copyfile(self.template_file_name, self.config_file_name) + + async def load(self, completion: bool = True) -> dict: + module_name = os.path.splitext(os.path.basename(self.config_file_name))[0] + module = importlib.import_module(module_name) + + cfg = {} + + allowed_types = (int, float, str, bool, list, dict) + + for key in dir(module): + if key.startswith('__'): + continue + + if not isinstance(getattr(module, key), allowed_types): + continue + + cfg[key] = getattr(module, key) + + # complete from template module file + if completion: + module_name = os.path.splitext(os.path.basename(self.template_file_name))[0] + module = importlib.import_module(module_name) + + for key in dir(module): + if key.startswith('__'): + continue + + if not isinstance(getattr(module, key), allowed_types): + continue + + if key not in cfg: + cfg[key] = getattr(module, key) + + return cfg + + async def save(self, data: dict): + logging.warning('Python module config file does not support saving') + + def save_sync(self, data: dict): + logging.warning('Python module config file does not support saving') diff --git a/src/langbot/pkg/config/impls/yaml.py b/src/langbot/pkg/config/impls/yaml.py new file mode 100644 index 0000000..d9dc4bc --- /dev/null +++ b/src/langbot/pkg/config/impls/yaml.py @@ -0,0 +1,71 @@ +import os +import yaml +import importlib.resources as resources + +from langbot.pkg.config import model as file_model + + +class YAMLConfigFile(file_model.ConfigFile): + """YAML config file""" + + def __init__( + self, + config_file_name: str, + template_resource_name: str = None, + template_data: dict = None, + ) -> None: + self.config_file_name = config_file_name + self.template_resource_name = template_resource_name + self.template_data = template_data + + def exists(self) -> bool: + return os.path.exists(self.config_file_name) + + async def get_template_file_str(self) -> str: + if self.template_resource_name is None: + return None + + with ( + resources.files('langbot.templates').joinpath(self.template_resource_name).open('r', encoding='utf-8') as f + ): + return f.read() + + async def create(self): + if await self.get_template_file_str() is not None: + with open(self.config_file_name, 'w', encoding='utf-8') as f: + f.write(await self.get_template_file_str()) + elif self.template_data is not None: + with open(self.config_file_name, 'w', encoding='utf-8') as f: + yaml.dump(self.template_data, f, indent=4, allow_unicode=True) + else: + raise ValueError('template_file_name or template_data must be provided') + + async def load(self, completion: bool = True) -> dict: + if not self.exists(): + await self.create() + + template_file_str = await self.get_template_file_str() + + if template_file_str is not None: + self.template_data = yaml.load(template_file_str, Loader=yaml.FullLoader) + + with open(self.config_file_name, 'r', encoding='utf-8') as f: + try: + cfg = yaml.load(f, Loader=yaml.FullLoader) + except yaml.YAMLError as e: + raise Exception(f'Syntax error in config file {self.config_file_name}: {e}') + + if completion: + for key in self.template_data: + if key not in cfg: + cfg[key] = self.template_data[key] + + return cfg + + async def save(self, cfg: dict): + with open(self.config_file_name, 'w', encoding='utf-8') as f: + yaml.dump(cfg, f, indent=4, allow_unicode=True) + + def save_sync(self, cfg: dict): + with open(self.config_file_name, 'w', encoding='utf-8') as f: + yaml.dump(cfg, f, indent=4, allow_unicode=True) diff --git a/src/langbot/pkg/config/manager.py b/src/langbot/pkg/config/manager.py new file mode 100644 index 0000000..d22591b --- /dev/null +++ b/src/langbot/pkg/config/manager.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from . import model as file_model +from .impls import pymodule, json as json_file, yaml as yaml_file + + +class ConfigManager: + """Config file manager""" + + name: str = None + """Config manager name""" + + description: str = None + """Config manager description""" + + schema: dict = None + """Config file schema + Must conform to JSON Schema Draft 7 specification + """ + + file: file_model.ConfigFile = None + """Config file instance""" + + data: dict = None + """Config data""" + + doc_link: str = None + """Config file documentation link""" + + def __init__(self, cfg_file: file_model.ConfigFile) -> None: + self.file = cfg_file + self.data = {} + + async def load_config(self, completion: bool = True): + self.data = await self.file.load(completion=completion) + + async def dump_config(self): + await self.file.save(self.data) + + def dump_config_sync(self): + self.file.save_sync(self.data) + + +async def load_python_module_config(config_name: str, template_name: str, completion: bool = True) -> ConfigManager: + """Load Python module config file + + Args: + config_name (str): Config file name + template_name (str): Template file name + completion (bool): Whether to automatically complete the config file in memory + + Returns: + ConfigManager: Config file manager + """ + cfg_inst = pymodule.PythonModuleConfigFile(config_name, template_name) + + cfg_mgr = ConfigManager(cfg_inst) + await cfg_mgr.load_config(completion=completion) + + return cfg_mgr + + +async def load_json_config( + config_name: str, + template_resource_name: str = None, + template_data: dict = None, + completion: bool = True, +) -> ConfigManager: + """Load JSON config file + + Args: + config_name (str): Config file name + template_resource_name (str): Template resource name + template_data (dict): Template data + completion (bool): Whether to automatically complete the config file in memory + """ + cfg_inst = json_file.JSONConfigFile(config_name, template_resource_name, template_data) + + cfg_mgr = ConfigManager(cfg_inst) + await cfg_mgr.load_config(completion=completion) + + return cfg_mgr + + +async def load_yaml_config( + config_name: str, + template_resource_name: str = None, + template_data: dict = None, + completion: bool = True, +) -> ConfigManager: + """Load YAML config file + + Args: + config_name (str): Config file name + template_resource_name (str): Template resource name + template_data (dict): Template data + completion (bool): Whether to automatically complete the config file in memory + + Returns: + ConfigManager: Config file manager + """ + cfg_inst = yaml_file.YAMLConfigFile(config_name, template_resource_name, template_data) + + cfg_mgr = ConfigManager(cfg_inst) + await cfg_mgr.load_config(completion=completion) + + return cfg_mgr diff --git a/src/langbot/pkg/config/model.py b/src/langbot/pkg/config/model.py new file mode 100644 index 0000000..8b040f0 --- /dev/null +++ b/src/langbot/pkg/config/model.py @@ -0,0 +1,34 @@ +import abc + + +class ConfigFile(metaclass=abc.ABCMeta): + """Config file abstract class""" + + config_file_name: str = None + """Config file name""" + + template_file_name: str = None + """Template file name""" + + template_data: dict = None + """Template data""" + + @abc.abstractmethod + def exists(self) -> bool: + pass + + @abc.abstractmethod + async def create(self): + pass + + @abc.abstractmethod + async def load(self, completion: bool = True) -> dict: + pass + + @abc.abstractmethod + async def save(self, data: dict): + pass + + @abc.abstractmethod + def save_sync(self, data: dict): + pass diff --git a/src/langbot/pkg/core/__init__.py b/src/langbot/pkg/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py new file mode 100644 index 0000000..b0adb55 --- /dev/null +++ b/src/langbot/pkg/core/app.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import logging +import asyncio +import traceback +import os + +from ..platform import botmgr as im_mgr +from ..platform.webhook_pusher import WebhookPusher +from ..provider.session import sessionmgr as llm_session_mgr +from ..provider.modelmgr import modelmgr as llm_model_mgr +from ..box import service as box_service_module + +from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr +from ..config import manager as config_mgr +from ..command import cmdmgr +from ..plugin import connector as plugin_connector +from ..pipeline import pool +from ..pipeline import controller, pipelinemgr +from ..pipeline import aggregator as message_aggregator +from ..utils import version as version_mgr, proxy as proxy_mgr +from ..persistence import mgr as persistencemgr +from ..api.http.controller import main as http_controller +from ..api.http.service import user as user_service +from ..api.http.service import space as space_service +from ..api.http.service import model as model_service +from ..api.http.service import provider as provider_service +from ..api.http.service import pipeline as pipeline_service +from ..api.http.service import bot as bot_service +from ..api.http.service import knowledge as knowledge_service +from ..api.http.service import mcp as mcp_service +from ..api.http.service import apikey as apikey_service +from ..api.http.service import webhook as webhook_service +from ..api.http.service import monitoring as monitoring_service +from ..api.http.service import skill as skill_service +from ..api.http.service import maintenance as maintenance_service +from ..discover import engine as discover_engine +from ..storage import mgr as storagemgr +from ..utils import logcache +from . import taskmgr +from . import entities as core_entities +from ..rag.knowledge import kbmgr as rag_mgr +from ..rag.service import RAGRuntimeService +from ..vector import mgr as vectordb_mgr +from ..telemetry import telemetry as telemetry_module +from ..survey import manager as survey_module +from ..skill import manager as skill_mgr + + +class Application: + """Runtime application object and context""" + + event_loop: asyncio.AbstractEventLoop = None + + # asyncio_tasks: list[asyncio.Task] = [] + task_mgr: taskmgr.AsyncTaskManager = None + + discover: discover_engine.ComponentDiscoveryEngine = None + + platform_mgr: im_mgr.PlatformManager = None + + webhook_pusher: WebhookPusher = None + + cmd_mgr: cmdmgr.CommandManager = None + + sess_mgr: llm_session_mgr.SessionManager = None + + model_mgr: llm_model_mgr.ModelManager = None + + rag_mgr: rag_mgr.RAGManager = None + rag_runtime_service: RAGRuntimeService = None + + # TODO move to pipeline + tool_mgr: llm_tool_mgr.ToolManager = None + box_service: box_service_module.BoxService = None + + # ======= Config manager ======= + + command_cfg: config_mgr.ConfigManager = None # deprecated + + pipeline_cfg: config_mgr.ConfigManager = None # deprecated + + platform_cfg: config_mgr.ConfigManager = None # deprecated + + provider_cfg: config_mgr.ConfigManager = None # deprecated + + system_cfg: config_mgr.ConfigManager = None # deprecated + + instance_config: config_mgr.ConfigManager = None + + instance_id: config_mgr.ConfigManager = None # used to identify the instance + + # ======= Metadata config manager ======= + + sensitive_meta: config_mgr.ConfigManager = None + + pipeline_config_meta_trigger: config_mgr.ConfigManager = None + pipeline_config_meta_safety: config_mgr.ConfigManager = None + pipeline_config_meta_ai: config_mgr.ConfigManager = None + pipeline_config_meta_output: config_mgr.ConfigManager = None + + # ========================= + + plugin_connector: plugin_connector.PluginRuntimeConnector = None + + query_pool: pool.QueryPool = None + + msg_aggregator: message_aggregator.MessageAggregator = None + + ctrl: controller.Controller = None + + pipeline_mgr: pipelinemgr.PipelineManager = None + + ver_mgr: version_mgr.VersionManager = None + + proxy_mgr: proxy_mgr.ProxyManager = None + + logger: logging.Logger = None + + persistence_mgr: persistencemgr.PersistenceManager = None + + vector_db_mgr: vectordb_mgr.VectorDBManager = None + + http_ctrl: http_controller.HTTPController = None + + log_cache: logcache.LogCache = None + + storage_mgr: storagemgr.StorageMgr = None + + # ========= HTTP Services ========= + + user_service: user_service.UserService = None + + space_service: space_service.SpaceService = None + + llm_model_service: model_service.LLMModelsService = None + + embedding_models_service: model_service.EmbeddingModelsService = None + + rerank_models_service: model_service.RerankModelsService = None + + provider_service: provider_service.ModelProviderService = None + + pipeline_service: pipeline_service.PipelineService = None + + bot_service: bot_service.BotService = None + + knowledge_service: knowledge_service.KnowledgeService = None + + mcp_service: mcp_service.MCPService = None + + apikey_service: apikey_service.ApiKeyService = None + + webhook_service: webhook_service.WebhookService = None + + telemetry: telemetry_module.TelemetryManager = None + + survey: survey_module.SurveyManager = None + + monitoring_service: monitoring_service.MonitoringService = None + + skill_service: skill_service.SkillService = None + + skill_mgr: skill_mgr.SkillManager = None + + maintenance_service: maintenance_service.MaintenanceService = None + + def __init__(self): + pass + + async def initialize(self): + pass + + async def run(self): + try: + await self.plugin_connector.initialize_plugins() + + # 后续可能会允许动态重启其他任务 + # 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程 + async def never_ending(): + while True: + await asyncio.sleep(1) + + self.task_mgr.create_task( + self.platform_mgr.run(), + name='platform-manager', + scopes=[ + core_entities.LifecycleControlScope.APPLICATION, + core_entities.LifecycleControlScope.PLATFORM, + ], + ) + self.task_mgr.create_task( + self.ctrl.run(), + name='query-controller', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + self.task_mgr.create_task( + self.http_ctrl.run(), + name='http-api-controller', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + + # Telemetry instance heartbeat (startup + daily); respects + # space.disable_telemetry via TelemetryManager.send(). + if self.telemetry is not None: + from ..telemetry import heartbeat as telemetry_heartbeat + + self.task_mgr.create_task( + telemetry_heartbeat.heartbeat_loop(self), + name='telemetry-heartbeat', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + + # Start monitoring data cleanup task if enabled + monitoring_cfg = self.instance_config.data.get('monitoring', {}) + auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {}) + if auto_cleanup_cfg.get('enabled', True): + retention_days = self._get_positive_int_config( + auto_cleanup_cfg.get('retention_days', 30), + default=30, + name='monitoring.auto_cleanup.retention_days', + ) + delete_batch_size = self._get_positive_int_config( + auto_cleanup_cfg.get('delete_batch_size', 1000), + default=1000, + name='monitoring.auto_cleanup.delete_batch_size', + ) + check_interval_hours = self._get_positive_float_config( + auto_cleanup_cfg.get('check_interval_hours', 1), + default=1, + name='monitoring.auto_cleanup.check_interval_hours', + ) + + async def monitoring_cleanup_loop(): + check_interval_seconds = check_interval_hours * 3600 + while True: + try: + deleted = await self.monitoring_service.cleanup_expired_records( + retention_days, + batch_size=delete_batch_size, + ) + total_deleted = sum(deleted.values()) + if total_deleted > 0: + self.logger.info( + f'Monitoring auto-cleanup: deleted {total_deleted} expired records ' + f'(retention={retention_days}d): {deleted}' + ) + except Exception as e: + self.logger.warning(f'Monitoring auto-cleanup error: {e}') + await asyncio.sleep(check_interval_seconds) + + self.task_mgr.create_task( + monitoring_cleanup_loop(), + name='monitoring-cleanup', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + + # Start storage/log maintenance task if enabled + storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {}) + if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None: + check_interval_hours = self._get_positive_float_config( + storage_cleanup_cfg.get('check_interval_hours', 1), + default=1, + name='storage.cleanup.check_interval_hours', + ) + + async def storage_cleanup_loop(): + check_interval_seconds = check_interval_hours * 3600 + while True: + try: + deleted = await self.maintenance_service.cleanup_expired_files() + total_deleted = sum(deleted.values()) + if total_deleted > 0: + self.logger.info(f'Storage maintenance: deleted expired files: {deleted}') + except Exception as e: + self.logger.warning(f'Storage maintenance error: {e}') + await asyncio.sleep(check_interval_seconds) + + self.task_mgr.create_task( + storage_cleanup_loop(), + name='storage-maintenance', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + + self.task_mgr.create_task( + never_ending(), + name='never-ending-task', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + + await self.print_web_access_info() + await self.task_mgr.wait_all() + except asyncio.CancelledError: + pass + except Exception as e: + self.logger.error(f'Application runtime fatal exception: {e}') + self.logger.debug(f'Traceback: {traceback.format_exc()}') + + def _get_positive_int_config(self, value, default: int, name: str) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + self.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + if parsed < 1: + self.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + return parsed + + def _get_positive_float_config(self, value, default: float, name: str) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + self.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + if parsed <= 0: + self.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + return parsed + + def dispose(self): + if self.plugin_connector is not None: + self.plugin_connector.dispose() + if self.box_service is not None: + self.box_service.dispose() + + async def print_web_access_info(self): + """Print access webui tips""" + + from ..utils import paths + + frontend_path = paths.get_frontend_path() + + if not os.path.exists(frontend_path): + self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh') + self.logger.warning( + 'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en' + ) + return + + host_ip = '127.0.0.1' + + port = self.instance_config.data['api']['port'] + + tips = f""" +======================================= +✨ Access WebUI / 访问管理面板 + +🏠 Local Address: http://{host_ip}:{port}/ +🌐 Public Address: http://:{port}/ + +📌 Running this program in a container? Please ensure that the {port} port is exposed +======================================= +""".strip() + for line in tips.split('\n'): + self.logger.info(line) diff --git a/src/langbot/pkg/core/boot.py b/src/langbot/pkg/core/boot.py new file mode 100644 index 0000000..f2b335a --- /dev/null +++ b/src/langbot/pkg/core/boot.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import traceback +import asyncio +import os + +from . import app +from . import stage +from ..utils import constants, importutil + +# Import startup stage implementation to register +from . import stages + +importutil.import_modules_in_pkg(stages) + + +stage_order = [ + 'LoadConfigStage', + 'GenKeysStage', + 'SetupLoggerStage', + 'BuildAppStage', + 'ShowNotesStage', +] + + +async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application: + # Determine if it is debug mode + if 'DEBUG' in os.environ and os.environ['DEBUG'] in ['true', '1']: + constants.debug_mode = True + + ap = app.Application() + + ap.event_loop = loop + + # Execute startup stage + for stage_name in stage_order: + stage_cls = stage.preregistered_stages[stage_name] + stage_inst = stage_cls() + + await stage_inst.run(ap) + + await ap.initialize() + + return ap + + +async def main(loop: asyncio.AbstractEventLoop): + app_inst: app.Application | None = None + try: + # Hang system signal processing + import signal + + def signal_handler(sig, frame): + if app_inst is not None: + app_inst.dispose() + print('[Signal] Program exit.') + os._exit(0) + + signal.signal(signal.SIGINT, signal_handler) + + app_inst = await make_app(loop) + await app_inst.run() + except Exception: + if app_inst is not None: + app_inst.dispose() + traceback.print_exc() diff --git a/src/langbot/pkg/core/bootutils/__init__.py b/src/langbot/pkg/core/bootutils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/core/bootutils/config.py b/src/langbot/pkg/core/bootutils/config.py new file mode 100644 index 0000000..cea4af4 --- /dev/null +++ b/src/langbot/pkg/core/bootutils/config.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +from ...config import manager as config_mgr + + +load_python_module_config = config_mgr.load_python_module_config +load_json_config = config_mgr.load_json_config +load_yaml_config = config_mgr.load_yaml_config diff --git a/src/langbot/pkg/core/bootutils/deps.py b/src/langbot/pkg/core/bootutils/deps.py new file mode 100644 index 0000000..2cfd57e --- /dev/null +++ b/src/langbot/pkg/core/bootutils/deps.py @@ -0,0 +1,82 @@ +import importlib.util +import pip +import os +from ...utils import pkgmgr + +# Check dependencies to prevent users from not installing +# Left is the import name, right is the dependency name +required_deps = { + 'requests': 'requests', + 'openai': 'openai', + 'anthropic': 'anthropic', + 'colorlog': 'colorlog', + 'aiocqhttp': 'aiocqhttp', + 'botpy': 'qq-botpy-rc', + 'PIL': 'pillow', + 'nakuru': 'nakuru-project-idk', + 'tiktoken': 'tiktoken', + 'yaml': 'pyyaml', + 'aiohttp': 'aiohttp', + 'psutil': 'psutil', + 'async_lru': 'async-lru', + 'ollama': 'ollama', + 'quart': 'quart', + 'quart_cors': 'quart-cors', + 'sqlalchemy': 'sqlalchemy[asyncio]', + 'aiosqlite': 'aiosqlite', + 'aiofiles': 'aiofiles', + 'aioshutil': 'aioshutil', + 'argon2': 'argon2-cffi', + 'jwt': 'pyjwt', + 'Crypto': 'pycryptodome', + 'lark_oapi': 'lark-oapi', + 'discord': 'discord.py', + 'cryptography': 'cryptography', + 'gewechat_client': 'gewechat-client', + 'dingtalk_stream': 'dingtalk_stream', + 'dashscope': 'dashscope', + 'telegram': 'python-telegram-bot', + 'certifi': 'certifi', + 'mcp': 'mcp', + 'sqlmodel': 'sqlmodel', + 'telegramify_markdown': 'telegramify-markdown', + 'slack_sdk': 'slack_sdk', + 'asyncpg': 'asyncpg', + 'litellm': 'litellm', +} + + +async def check_deps() -> list[str]: + global required_deps + + missing_deps = [] + for dep in required_deps: + # Use find_spec instead of __import__ to avoid actually loading + # all modules into memory. find_spec only checks if the module + # can be found, without executing module-level code. + if importlib.util.find_spec(dep) is None: + missing_deps.append(dep) + return missing_deps + + +async def install_deps(deps: list[str]): + global required_deps + + for dep in deps: + pip.main(['install', required_deps[dep]]) + + +async def precheck_plugin_deps(): + print('[Startup] Prechecking plugin dependencies...') + + # Only execute plugin dependency installation when the plugins directory exists + if os.path.exists('plugins'): + for dir in os.listdir('plugins'): + subdir = os.path.join('plugins', dir) + if not os.path.isdir(subdir): + continue + if 'requirements.txt' in os.listdir(subdir): + pkgmgr.install_requirements( + os.path.join(subdir, 'requirements.txt'), + extra_params=[], + ) diff --git a/src/langbot/pkg/core/bootutils/files.py b/src/langbot/pkg/core/bootutils/files.py new file mode 100644 index 0000000..067b60c --- /dev/null +++ b/src/langbot/pkg/core/bootutils/files.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import os +import shutil + + +required_files = { + 'data/config.yaml': 'templates/config.yaml', +} + +required_paths = [ + 'temp', + 'data', + 'data/metadata', + 'data/logs', + 'data/labels', +] + + +async def generate_files() -> list[str]: + global required_files, required_paths + + from ...utils import paths as path_utils + + for required_paths in required_paths: + if not os.path.exists(required_paths): + os.mkdir(required_paths) + + generated_files = [] + for file in required_files: + if not os.path.exists(file): + template_path = path_utils.get_resource_path(required_files[file]) + shutil.copyfile(template_path, file) + generated_files.append(file) + + return generated_files diff --git a/src/langbot/pkg/core/bootutils/log.py b/src/langbot/pkg/core/bootutils/log.py new file mode 100644 index 0000000..7b78c93 --- /dev/null +++ b/src/langbot/pkg/core/bootutils/log.py @@ -0,0 +1,140 @@ +import logging +import logging.handlers +import os +import sys +import time + +import colorlog + +from ...utils import constants + + +log_colors_config = { + 'DEBUG': 'green', # cyan white + 'INFO': 'white', + 'WARNING': 'yellow', + 'ERROR': 'red', + 'CRITICAL': 'cyan', +} + +# Log rotation configuration to prevent unbounded log file growth +LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file +LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max) + +LOG_DIR = 'data/logs' + + +class DailyGroupedRotatingFileHandler(logging.handlers.RotatingFileHandler): + """File handler that writes to ``data/logs/langbot-YYYY-MM-DD.log``. + + It combines two rotation triggers: + + * **Size** — within a single day the file is rotated once it exceeds + ``maxBytes``, producing numbered backups (``langbot-DATE.log.1`` etc.), + exactly like :class:`~logging.handlers.RotatingFileHandler`. + * **Date** — when the local date changes, logging switches to a fresh + ``langbot-.log`` file. This happens even within a single + long-running process, so a bot started on day N keeps writing to that + day's file and rolls over to day N+1's file at midnight, instead of + appending every subsequent day's logs to the start-day file. + + The on-disk naming stays compatible with the log-retention cleanup in + ``api/http/service/maintenance.py`` (``LOG_FILE_PATTERN``). + """ + + def __init__(self, log_dir: str, max_bytes: int, backup_count: int, encoding: str = 'utf-8'): + self.log_dir = log_dir + self._current_date = self._today() + super().__init__( + self._build_path(self._current_date), + maxBytes=max_bytes, + backupCount=backup_count, + encoding=encoding, + ) + + @staticmethod + def _today() -> str: + return time.strftime('%Y-%m-%d', time.localtime()) + + def _build_path(self, date_str: str) -> str: + return os.path.join(self.log_dir, 'langbot-%s.log' % date_str) + + def shouldRollover(self, record): + # Roll over when the day changes, regardless of file size. + if self._today() != self._current_date: + return True + return super().shouldRollover(record) + + def doRollover(self): + today = self._today() + if today != self._current_date: + # Date changed: point the handler at the new day's file. + # This is a date switch, not a size-based numbered rotation. + if self.stream: + self.stream.close() + self.stream = None + self._current_date = today + self.baseFilename = os.path.abspath(self._build_path(today)) + if not self.delay: + self.stream = self._open() + else: + # Same day, file exceeded maxBytes: numbered rotation. + super().doRollover() + + +async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger: + # Remove all existing loggers + for handler in logging.root.handlers[:]: + logging.root.removeHandler(handler) + + level = logging.INFO + + if constants.debug_mode: + level = logging.DEBUG + + qcg_logger = logging.getLogger('langbot') + + qcg_logger.setLevel(level) + + color_formatter = colorlog.ColoredFormatter( + fmt='%(log_color)s[%(asctime)s.%(msecs)03d] %(filename)s (%(lineno)d) - [%(levelname)s] : %(message)s', + datefmt='%m-%d %H:%M:%S', + log_colors=log_colors_config, + ) + + stream_handler = logging.StreamHandler(sys.stdout) + # stream_handler.setLevel(level) + # stream_handler.setFormatter(color_formatter) + stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1) + + # Rotate by size within a day and switch files when the date changes, + # so long-running processes still produce a log file for the current day. + rotating_file_handler = DailyGroupedRotatingFileHandler( + LOG_DIR, + max_bytes=LOG_FILE_MAX_BYTES, + backup_count=LOG_FILE_BACKUP_COUNT, + encoding='utf-8', + ) + + log_handlers: list[logging.Handler] = [ + stream_handler, + rotating_file_handler, + ] + log_handlers += extra_handlers if extra_handlers is not None else [] + + for handler in log_handlers: + handler.setLevel(level) + handler.setFormatter(color_formatter) + qcg_logger.addHandler(handler) + + qcg_logger.debug('Logging initialized, log level: %s' % level) + logging.basicConfig( + level=logging.CRITICAL, # Set log output format + format='[DEPR][%(asctime)s.%(msecs)03d] %(pathname)s (%(lineno)d) - [%(levelname)s] :\n%(message)s', + # Log output format + # -8 is a placeholder, left-align the output, and output length is 8 + datefmt='%Y-%m-%d %H:%M:%S', # Time output format + handlers=[logging.NullHandler()], + ) + + return qcg_logger diff --git a/src/langbot/pkg/core/entities.py b/src/langbot/pkg/core/entities.py new file mode 100644 index 0000000..4383f07 --- /dev/null +++ b/src/langbot/pkg/core/entities.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import enum + + +class LifecycleControlScope(enum.Enum): + APPLICATION = 'application' + PLATFORM = 'platform' + PLUGIN = 'plugin' + PROVIDER = 'provider' diff --git a/src/langbot/pkg/core/note.py b/src/langbot/pkg/core/note.py new file mode 100644 index 0000000..b4c37ce --- /dev/null +++ b/src/langbot/pkg/core/note.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import abc +import typing + +from . import app + +preregistered_notes: list[typing.Type[LaunchNote]] = [] + + +def note_class(name: str, number: int): + """Register a launch information""" + + def decorator(cls: typing.Type[LaunchNote]) -> typing.Type[LaunchNote]: + cls.name = name + cls.number = number + preregistered_notes.append(cls) + return cls + + return decorator + + +class LaunchNote(abc.ABC): + """Launch information""" + + name: str + + number: int + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + @abc.abstractmethod + async def need_show(self) -> bool: + """Determine if the current environment needs to display this launch information""" + pass + + @abc.abstractmethod + async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]: + """Generate launch information""" + pass diff --git a/src/langbot/pkg/core/notes/__init__.py b/src/langbot/pkg/core/notes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/core/notes/n001_classic_msgs.py b/src/langbot/pkg/core/notes/n001_classic_msgs.py new file mode 100644 index 0000000..190958c --- /dev/null +++ b/src/langbot/pkg/core/notes/n001_classic_msgs.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import typing + +from .. import note + + +@note.note_class('ClassicNotes', 1) +class ClassicNotes(note.LaunchNote): + """Classic launch information""" + + async def need_show(self) -> bool: + return True + + async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]: + yield await self.ap.ver_mgr.show_version_update() diff --git a/src/langbot/pkg/core/notes/n002_selection_mode_on_windows.py b/src/langbot/pkg/core/notes/n002_selection_mode_on_windows.py new file mode 100644 index 0000000..16028de --- /dev/null +++ b/src/langbot/pkg/core/notes/n002_selection_mode_on_windows.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import typing +import os +import logging + +from .. import note + + +@note.note_class('SelectionModeOnWindows', 2) +class SelectionModeOnWindows(note.LaunchNote): + """Selection mode prompt information on Windows""" + + async def need_show(self) -> bool: + return os.name == 'nt' + + async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]: + yield ( + """您正在使用 Windows 系统,若窗口左上角显示处于”选择“模式,程序将被暂停运行,此时请右键窗口中空白区域退出选择模式。""", + logging.INFO, + ) + + yield ( + """You are using Windows system, if the top left corner of the window displays "Selection" mode, the program will be paused running, please right-click on the blank area in the window to exit the selection mode.""", + logging.INFO, + ) diff --git a/src/langbot/pkg/core/notes/n003_print_version.py b/src/langbot/pkg/core/notes/n003_print_version.py new file mode 100644 index 0000000..18eebf4 --- /dev/null +++ b/src/langbot/pkg/core/notes/n003_print_version.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import typing +import logging + +from .. import note + + +@note.note_class('PrintVersion', 3) +class PrintVersion(note.LaunchNote): + """Print Version Information""" + + async def need_show(self) -> bool: + return True + + async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]: + yield f'Current Version: {self.ap.ver_mgr.get_current_version()}', logging.INFO diff --git a/src/langbot/pkg/core/stage.py b/src/langbot/pkg/core/stage.py new file mode 100644 index 0000000..1483e23 --- /dev/null +++ b/src/langbot/pkg/core/stage.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import abc +import typing + +from . import app + + +preregistered_stages: dict[str, typing.Type[BootingStage]] = {} +"""Pre-registered request processing stages. All request processing stage classes are registered in this dictionary during initialization. + +Currently not supported for extension +""" + + +def stage_class(name: str): + def decorator(cls: typing.Type[BootingStage]) -> typing.Type[BootingStage]: + preregistered_stages[name] = cls + return cls + + return decorator + + +class BootingStage(abc.ABC): + """Booting stage""" + + name: str = None + + @abc.abstractmethod + async def run(self, ap: app.Application): + """Run""" + pass diff --git a/src/langbot/pkg/core/stages/__init__.py b/src/langbot/pkg/core/stages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py new file mode 100644 index 0000000..a8d53b7 --- /dev/null +++ b/src/langbot/pkg/core/stages/build_app.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import asyncio + +from .. import stage, app +from ...utils import version, proxy +from ...pipeline import pool, controller, pipelinemgr +from ...pipeline import aggregator as message_aggregator +from ...box import service as box_service +from ...plugin import connector as plugin_connector +from ...command import cmdmgr +from ...provider.session import sessionmgr as llm_session_mgr +from ...provider.modelmgr import modelmgr as llm_model_mgr +from ...provider.tools import toolmgr as llm_tool_mgr +from ...rag.knowledge import kbmgr as rag_mgr +from ...rag.service import RAGRuntimeService +from ...platform import botmgr as im_mgr +from ...platform.webhook_pusher import WebhookPusher +from ...persistence import mgr as persistencemgr +from ...api.http.controller import main as http_controller +from ...api.http.service import user as user_service +from ...api.http.service import space as space_service +from ...api.http.service import model as model_service +from ...api.http.service import provider as provider_service +from ...api.http.service import pipeline as pipeline_service +from ...api.http.service import bot as bot_service +from ...api.http.service import knowledge as knowledge_service +from ...api.http.service import mcp as mcp_service +from ...api.http.service import apikey as apikey_service +from ...api.http.service import webhook as webhook_service +from ...api.http.service import monitoring as monitoring_service +from ...api.http.service import skill as skill_service +from ...skill import manager as skill_mgr +from ...api.http.service import maintenance as maintenance_service +from ...discover import engine as discover_engine +from ...storage import mgr as storagemgr +from ...utils import logcache +from ...vector import mgr as vectordb_mgr +from .. import taskmgr +from ...telemetry import telemetry as telemetry_module +from ...survey import manager as survey_module + + +@stage.stage_class('BuildAppStage') +class BuildAppStage(stage.BootingStage): + """Build LangBot application""" + + async def run(self, ap: app.Application): + """Build LangBot application""" + ap.task_mgr = taskmgr.AsyncTaskManager(ap) + + discover = discover_engine.ComponentDiscoveryEngine(ap) + discover.discover_blueprint('templates/components.yaml') + ap.discover = discover + + user_service_inst = user_service.UserService(ap) + ap.user_service = user_service_inst + + space_service_inst = space_service.SpaceService(ap) + ap.space_service = space_service_inst + + llm_model_service_inst = model_service.LLMModelsService(ap) + ap.llm_model_service = llm_model_service_inst + + embedding_models_service_inst = model_service.EmbeddingModelsService(ap) + ap.embedding_models_service = embedding_models_service_inst + + rerank_models_service_inst = model_service.RerankModelsService(ap) + ap.rerank_models_service = rerank_models_service_inst + + provider_service_inst = provider_service.ModelProviderService(ap) + ap.provider_service = provider_service_inst + + pipeline_service_inst = pipeline_service.PipelineService(ap) + ap.pipeline_service = pipeline_service_inst + + bot_service_inst = bot_service.BotService(ap) + ap.bot_service = bot_service_inst + + knowledge_service_inst = knowledge_service.KnowledgeService(ap) + ap.knowledge_service = knowledge_service_inst + + mcp_service_inst = mcp_service.MCPService(ap) + ap.mcp_service = mcp_service_inst + + apikey_service_inst = apikey_service.ApiKeyService(ap) + ap.apikey_service = apikey_service_inst + + webhook_service_inst = webhook_service.WebhookService(ap) + ap.webhook_service = webhook_service_inst + + skill_service_inst = skill_service.SkillService(ap) + ap.skill_service = skill_service_inst + + proxy_mgr = proxy.ProxyManager(ap) + await proxy_mgr.initialize() + ap.proxy_mgr = proxy_mgr + + ver_mgr = version.VersionManager(ap) + await ver_mgr.initialize() + ap.ver_mgr = ver_mgr + + ap.query_pool = pool.QueryPool() + + log_cache = logcache.LogCache() + ap.log_cache = log_cache + + storage_mgr_inst = storagemgr.StorageMgr(ap) + await storage_mgr_inst.initialize() + ap.storage_mgr = storage_mgr_inst + + persistence_mgr_inst = persistencemgr.PersistenceManager(ap) + ap.persistence_mgr = persistence_mgr_inst + await persistence_mgr_inst.initialize() + + # Telemetry manager: attach to app so other components can call via self.ap.telemetry + telemetry_inst = telemetry_module.TelemetryManager(ap) + await telemetry_inst.initialize() + ap.telemetry = telemetry_inst + + # Survey manager + survey_inst = survey_module.SurveyManager(ap) + await survey_inst.initialize() + ap.survey = survey_inst + + cmd_mgr_inst = cmdmgr.CommandManager(ap) + await cmd_mgr_inst.initialize() + ap.cmd_mgr = cmd_mgr_inst + + llm_model_mgr_inst = llm_model_mgr.ModelManager(ap) + ap.model_mgr = llm_model_mgr_inst + await llm_model_mgr_inst.initialize() + + llm_session_mgr_inst = llm_session_mgr.SessionManager(ap) + await llm_session_mgr_inst.initialize() + ap.sess_mgr = llm_session_mgr_inst + + box_service_inst = box_service.BoxService(ap) + await box_service_inst.initialize() + ap.box_service = box_service_inst + + llm_tool_mgr_inst = llm_tool_mgr.ToolManager(ap) + await llm_tool_mgr_inst.initialize() + ap.tool_mgr = llm_tool_mgr_inst + + im_mgr_inst = im_mgr.PlatformManager(ap=ap) + await im_mgr_inst.initialize() + ap.platform_mgr = im_mgr_inst + + # Initialize webhook pusher + webhook_pusher_inst = WebhookPusher(ap) + ap.webhook_pusher = webhook_pusher_inst + + pipeline_mgr = pipelinemgr.PipelineManager(ap) + await pipeline_mgr.initialize() + ap.pipeline_mgr = pipeline_mgr + + # Initialize message aggregator (after pipeline_mgr, as it needs pipeline config) + msg_aggregator_inst = message_aggregator.MessageAggregator(ap) + ap.msg_aggregator = msg_aggregator_inst + + # Initialize skill manager + skill_mgr_inst = skill_mgr.SkillManager(ap) + await skill_mgr_inst.initialize() + ap.skill_mgr = skill_mgr_inst + + rag_mgr_inst = rag_mgr.RAGManager(ap) + await rag_mgr_inst.initialize() + ap.rag_mgr = rag_mgr_inst + + # Initialize RAG Runtime Service for plugins + ap.rag_runtime_service = RAGRuntimeService(ap) + + # 初始化向量数据库管理器 + vectordb_mgr_inst = vectordb_mgr.VectorDBManager(ap) + await vectordb_mgr_inst.initialize() + ap.vector_db_mgr = vectordb_mgr_inst + + http_ctrl = http_controller.HTTPController(ap) + await http_ctrl.initialize() + ap.http_ctrl = http_ctrl + + monitoring_service_inst = monitoring_service.MonitoringService(ap) + ap.monitoring_service = monitoring_service_inst + + maintenance_service_inst = maintenance_service.MaintenanceService(ap) + ap.maintenance_service = maintenance_service_inst + + async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None: + await asyncio.sleep(3) + await plugin_connector_inst.initialize() + + plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback) + await plugin_connector_inst.initialize() + ap.plugin_connector = plugin_connector_inst + + ctrl = controller.Controller(ap) + ap.ctrl = ctrl diff --git a/src/langbot/pkg/core/stages/genkeys.py b/src/langbot/pkg/core/stages/genkeys.py new file mode 100644 index 0000000..f0412b9 --- /dev/null +++ b/src/langbot/pkg/core/stages/genkeys.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import secrets + +from .. import stage, app + + +@stage.stage_class('GenKeysStage') +class GenKeysStage(stage.BootingStage): + """Generate keys stage""" + + async def run(self, ap: app.Application): + """Generate keys""" + + if not ap.instance_config.data['system']['jwt']['secret']: + ap.instance_config.data['system']['jwt']['secret'] = secrets.token_hex(16) + await ap.instance_config.dump_config() + + if 'recovery_key' not in ap.instance_config.data['system']: + ap.instance_config.data['system']['recovery_key'] = '' + + if not ap.instance_config.data['system']['recovery_key']: + ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper() + await ap.instance_config.dump_config() diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py new file mode 100644 index 0000000..6fc890f --- /dev/null +++ b/src/langbot/pkg/core/stages/load_config.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import os +from typing import Any +from langbot.pkg.utils import constants +import yaml +import importlib.resources as resources +import uuid +import time + +from .. import stage, app +from ..bootutils import config + + +def _apply_env_overrides_to_config(cfg: dict) -> dict: + """Apply environment variable overrides to data/config.yaml + + Environment variables should be uppercase and use __ (double underscore) + to represent nested keys. For example: + - CONCURRENCY__PIPELINE overrides concurrency.pipeline + - PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url + + Arrays and dict types are ignored. + + Args: + cfg: Configuration dictionary + + Returns: + Updated configuration dictionary + """ + + def convert_value(value: str, original_value: Any) -> Any: + """Convert string value to appropriate type based on original value + + Args: + value: String value from environment variable + original_value: Original value to infer type from + + Returns: + Converted value (falls back to string if conversion fails) + """ + if isinstance(original_value, bool): + return value.lower() in ('true', '1', 'yes', 'on') + elif isinstance(original_value, int): + try: + return int(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + elif isinstance(original_value, float): + try: + return float(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + else: + return value + + # Process environment variables + for env_key, env_value in os.environ.items(): + # Check if the environment variable is uppercase and contains __ + if not env_key.isupper(): + continue + if '__' not in env_key: + continue + + print(f'apply env overrides to config: env_key: {env_key}, env_value: {env_value}') + + # Convert environment variable name to config path + # e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline'] + keys = [key.lower() for key in env_key.split('__')] + + # Navigate to the target value and validate the path + current = cfg + + for i, key in enumerate(keys): + if not isinstance(current, dict): + break + + if i == len(keys) - 1: + # At the final key + if key in current: + if isinstance(current[key], list): + # Convert comma-separated string to list + # e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk" + current[key] = [item.strip() for item in env_value.split(',') if item.strip()] + elif isinstance(current[key], dict): + # Skip dict types + pass + else: + # Valid scalar value - convert and set it + converted_value = convert_value(env_value, current[key]) + current[key] = converted_value + else: + # Key doesn't exist yet - create it as string + current[key] = env_value + else: + # Navigate deeper - create intermediate dict if needed + if key not in current: + current[key] = {} + current = current[key] + + return cfg + + +@stage.stage_class('LoadConfigStage') +class LoadConfigStage(stage.BootingStage): + """Load config file stage""" + + async def run(self, ap: app.Application): + """Load config file""" + + # # ======= deprecated ======= + # if os.path.exists('data/config/command.json'): + # ap.command_cfg = await config.load_json_config( + # 'data/config/command.json', + # 'templates/legacy/command.json', + # completion=False, + # ) + + # if os.path.exists('data/config/pipeline.json'): + # ap.pipeline_cfg = await config.load_json_config( + # 'data/config/pipeline.json', + # 'templates/legacy/pipeline.json', + # completion=False, + # ) + + # if os.path.exists('data/config/platform.json'): + # ap.platform_cfg = await config.load_json_config( + # 'data/config/platform.json', + # 'templates/legacy/platform.json', + # completion=False, + # ) + + # if os.path.exists('data/config/provider.json'): + # ap.provider_cfg = await config.load_json_config( + # 'data/config/provider.json', + # 'templates/legacy/provider.json', + # completion=False, + # ) + + # if os.path.exists('data/config/system.json'): + # ap.system_cfg = await config.load_json_config( + # 'data/config/system.json', + # 'templates/legacy/system.json', + # completion=False, + # ) + + # # ======= deprecated ======= + + ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False) + + # Apply environment variable overrides to data/config.yaml + ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data) + + await ap.instance_config.dump_config() + + # load or generate instance id + # Priority: + # 1. system.instance_id from config.yaml (can be set via SYSTEM__INSTANCE_ID env var) + # 2. data/labels/instance_id.json (if file exists) + # 3. Generate new and save to file + config_instance_id = ap.instance_config.data.get('system', {}).get('instance_id', '') + + if config_instance_id: + # Use the instance_id from config.yaml + constants.instance_id = config_instance_id + # Still load/create the file for backward compat, but don't use its value + ap.instance_id = await config.load_json_config( + 'data/labels/instance_id.json', + template_data={ + 'instance_id': f'instance_{str(uuid.uuid4())}', + 'instance_create_ts': int(time.time()), + }, + completion=False, + ) + else: + # Try loading file-based instance id + instance_id_path = os.path.join('data', 'labels', 'instance_id.json') + if os.path.exists(instance_id_path): + # File exists, read it + ap.instance_id = await config.load_json_config( + 'data/labels/instance_id.json', + template_data={ + 'instance_id': '', + 'instance_create_ts': 0, + }, + completion=False, + ) + constants.instance_id = ap.instance_id.data['instance_id'] + else: + # Neither config nor file, generate new and save to file + new_id = f'instance_{str(uuid.uuid4())}' + ap.instance_id = await config.load_json_config( + 'data/labels/instance_id.json', + template_data={ + 'instance_id': new_id, + 'instance_create_ts': int(time.time()), + }, + completion=False, + ) + constants.instance_id = new_id + constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community') + + # Instance creation timestamp: sourced from data/labels/instance_id.json. + # Instances created before this field existed (or supplied via + # system.instance_id) won't have it, so backfill with the current time + # and persist it via the dump below — from then on it stays stable. + instance_create_ts = ap.instance_id.data.get('instance_create_ts', 0) + if not isinstance(instance_create_ts, int) or instance_create_ts <= 0: + instance_create_ts = int(time.time()) + ap.instance_id.data['instance_create_ts'] = instance_create_ts + constants.instance_create_ts = instance_create_ts + + print(f'LangBot instance id: {constants.instance_id}') + print(f'LangBot edition: {constants.edition}') + + await ap.instance_id.dump_config() + + ap.sensitive_meta = await config.load_json_config( + 'data/metadata/sensitive-words.json', + 'metadata/sensitive-words.json', + ) + await ap.sensitive_meta.dump_config() + + async def load_resource_yaml_template_data(resource_name: str) -> dict: + with resources.files('langbot.templates').joinpath(resource_name).open('r', encoding='utf-8') as f: + return yaml.load(f, Loader=yaml.FullLoader) + + ap.pipeline_config_meta_trigger = await load_resource_yaml_template_data('metadata/pipeline/trigger.yaml') + ap.pipeline_config_meta_safety = await load_resource_yaml_template_data('metadata/pipeline/safety.yaml') + ap.pipeline_config_meta_ai = await load_resource_yaml_template_data('metadata/pipeline/ai.yaml') + ap.pipeline_config_meta_output = await load_resource_yaml_template_data('metadata/pipeline/output.yaml') diff --git a/src/langbot/pkg/core/stages/setup_logger.py b/src/langbot/pkg/core/stages/setup_logger.py new file mode 100644 index 0000000..1f7c81a --- /dev/null +++ b/src/langbot/pkg/core/stages/setup_logger.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import logging + +from .. import stage, app +from ..bootutils import log + + +class PersistenceHandler(logging.Handler, object): + """ + Save logs to database + """ + + ap: app.Application + + def __init__(self, name, ap: app.Application): + logging.Handler.__init__(self) + self.ap = ap + + def emit(self, record): + """ + emit function is a required function for custom handler classes, here you can process the log messages as needed, such as sending logs to the server + + Emit a record + """ + try: + msg = self.format(record) + if self.ap.log_cache is not None: + self.ap.log_cache.add_log(msg) + + except Exception: + self.handleError(record) + + +@stage.stage_class('SetupLoggerStage') +class SetupLoggerStage(stage.BootingStage): + """Setup logger stage""" + + async def run(self, ap: app.Application): + """Setup logger""" + persistence_handler = PersistenceHandler('LoggerHandler', ap) + + extra_handlers = [] + extra_handlers = [persistence_handler] + + ap.logger = await log.init_logging(extra_handlers) diff --git a/src/langbot/pkg/core/stages/show_notes.py b/src/langbot/pkg/core/stages/show_notes.py new file mode 100644 index 0000000..d0f861b --- /dev/null +++ b/src/langbot/pkg/core/stages/show_notes.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import asyncio + +from .. import stage, app, note +from ...utils import importutil + +from .. import notes + +importutil.import_modules_in_pkg(notes) + + +@stage.stage_class('ShowNotesStage') +class ShowNotesStage(stage.BootingStage): + """Show notes stage""" + + async def run(self, ap: app.Application): + # Sort + note.preregistered_notes.sort(key=lambda x: x.number) + + for note_cls in note.preregistered_notes: + try: + note_inst = note_cls(ap) + if await note_inst.need_show(): + + async def ayield_note(note_inst: note.LaunchNote): + async for ret in note_inst.yield_note(): + if not ret: + continue + msg, level = ret + if msg: + ap.logger.log(level, msg) + + asyncio.create_task(ayield_note(note_inst)) + except Exception: + continue diff --git a/src/langbot/pkg/core/taskmgr.py b/src/langbot/pkg/core/taskmgr.py new file mode 100644 index 0000000..8bf8784 --- /dev/null +++ b/src/langbot/pkg/core/taskmgr.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import asyncio +import typing +import datetime +import time + +from . import app +from . import entities as core_entities + + +class TaskContext: + """Task tracking context""" + + current_action: str + """Current action being executed""" + + log: str + """Log""" + + metadata: dict + """Structured metadata for progress reporting""" + + def __init__(self): + self.current_action = 'default' + self.log = '' + self.metadata = {} + + def _log(self, msg: str): + self.log += msg + '\n' + + def set_current_action(self, action: str): + self.current_action = action + + def trace( + self, + msg: str, + action: str = None, + ): + if action is not None: + self.set_current_action(action) + + self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}') + + def to_dict(self) -> dict: + return {'current_action': self.current_action, 'log': self.log, 'metadata': self.metadata} + + @staticmethod + def new() -> TaskContext: + return TaskContext() + + @staticmethod + def placeholder() -> TaskContext: + global placeholder_context + + if placeholder_context is None: + placeholder_context = TaskContext() + + return placeholder_context + + +placeholder_context: TaskContext | None = None + + +class TaskWrapper: + """Task wrapper""" + + _id_index: int = 0 + """Task ID index""" + + id: int + """Task ID""" + + task_type: str = 'system' # Task type: system or user + """Task type""" + + kind: str = 'system_task' # Task type determined by the initiator, usually the same task type + """Task type""" + + name: str = '' + """Task unique name""" + + label: str = '' + """Task display name""" + + task_context: TaskContext + """Task context""" + + task: asyncio.Task + """Task""" + + task_stack: list = None + """Task stack""" + + ap: app.Application + """Application instance""" + + scopes: list[core_entities.LifecycleControlScope] + """Task scope""" + + def __init__( + self, + ap: app.Application, + coro: typing.Coroutine, + task_type: str = 'system', + kind: str = 'system_task', + name: str = '', + label: str = '', + context: TaskContext = None, + scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION], + ): + self.id = TaskWrapper._id_index + TaskWrapper._id_index += 1 + self.ap = ap + self.task_context = context or TaskContext() + self.task = self.ap.event_loop.create_task(coro) + self.task_type = task_type + self.kind = kind + self.name = name + self.label = label if label != '' else name + self.task.set_name(name) + self.scopes = scopes + self.created_at = time.time() + + def assume_exception(self): + try: + exception = self.task.exception() + if self.task_stack is None: + self.task_stack = self.task.get_stack() + return exception + except Exception: + return None + + def assume_result(self): + try: + return self.task.result() + except Exception: + return None + + def to_dict(self) -> dict: + exception_traceback = None + if self.assume_exception() is not None: + exception_traceback = 'Traceback (most recent call last):\n' + + for frame in self.task_stack: + exception_traceback += ( + f' File "{frame.f_code.co_filename}", line {frame.f_lineno}, in {frame.f_code.co_name}\n' + ) + + exception_traceback += f' {self.assume_exception().__str__()}\n' + + return { + 'id': self.id, + 'task_type': self.task_type, + 'kind': self.kind, + 'name': self.name, + 'label': self.label, + 'scopes': [scope.value for scope in self.scopes], + 'created_at': self.created_at, + 'task_context': self.task_context.to_dict(), + 'runtime': { + 'done': self.task.done(), + 'state': self.task._state, + 'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None, + 'exception_traceback': exception_traceback, + 'result': self.assume_result() if self.assume_result() is not None else None, + }, + } + + def cancel(self): + self.task.cancel() + + +class AsyncTaskManager: + """Save all asynchronous tasks in the app + Include system-level and user-level (plugin installation, update, etc. initiated by users directly)""" + + ap: app.Application + + tasks: list[TaskWrapper] + """All tasks""" + + def __init__(self, ap: app.Application): + self.ap = ap + self.tasks = [] + + def create_task( + self, + coro: typing.Coroutine, + task_type: str = 'system', + kind: str = 'system-task', + name: str = '', + label: str = '', + context: TaskContext = None, + scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION], + ) -> TaskWrapper: + wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes) + self.tasks.append(wrapper) + wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks()) + self._prune_completed_tasks() + return wrapper + + def create_user_task( + self, + coro: typing.Coroutine, + kind: str = 'user-task', + name: str = '', + label: str = '', + context: TaskContext = None, + scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION], + ) -> TaskWrapper: + return self.create_task(coro, 'user', kind, name, label, context, scopes) + + async def wait_all(self): + await asyncio.gather(*[t.task for t in self.tasks], return_exceptions=True) + + def get_all_tasks(self) -> list[TaskWrapper]: + return self.tasks + + def get_tasks_dict( + self, + type: str = None, + kind: str = None, + ) -> dict: + return { + 'tasks': [ + t.to_dict() + for t in self.tasks + if (type is None or t.task_type == type) and (kind is None or t.kind == kind) + ], + 'id_index': TaskWrapper._id_index, + } + + def get_stats(self) -> dict: + completed = sum(1 for t in self.tasks if t.task.done()) + return { + 'total': len(self.tasks), + 'running': len(self.tasks) - completed, + 'completed': completed, + 'id_index': TaskWrapper._id_index, + } + + def get_task_by_id(self, id: int) -> TaskWrapper | None: + for t in self.tasks: + if t.id == id: + return t + return None + + def cancel_by_scope(self, scope: core_entities.LifecycleControlScope): + for wrapper in self.tasks: + if not wrapper.task.done() and scope in wrapper.scopes: + wrapper.task.cancel() + + def cancel_task(self, task_id: int): + for wrapper in self.tasks: + if wrapper.id == task_id: + if not wrapper.task.done(): + wrapper.task.cancel() + return + + def _prune_completed_tasks(self): + completed_limit = ( + self.ap.instance_config.data.get('system', {}) + .get('task_retention', {}) + .get( + 'completed_limit', + 200, + ) + ) + try: + completed_limit = int(completed_limit) + except (TypeError, ValueError): + completed_limit = 200 + if completed_limit < 1: + completed_limit = 1 + + completed_tasks = [wrapper for wrapper in self.tasks if wrapper.task.done()] + overflow = len(completed_tasks) - completed_limit + if overflow <= 0: + return + + remove_ids = {wrapper.id for wrapper in completed_tasks[:overflow]} + self.tasks = [wrapper for wrapper in self.tasks if wrapper.id not in remove_ids] diff --git a/src/langbot/pkg/discover/__init__.py b/src/langbot/pkg/discover/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/discover/engine.py b/src/langbot/pkg/discover/engine.py new file mode 100644 index 0000000..713420d --- /dev/null +++ b/src/langbot/pkg/discover/engine.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import typing +import importlib +import os +import yaml +import pydantic + +from langbot.pkg.core import app +from langbot.pkg.utils import importutil + + +class I18nString(pydantic.BaseModel): + """国际化字符串""" + + en_US: str + """英文""" + + zh_Hans: typing.Optional[str] = None + """简体中文""" + + zh_Hant: typing.Optional[str] = None + """繁体中文""" + + ja_JP: typing.Optional[str] = None + """日文""" + + th_TH: typing.Optional[str] = None + """泰文""" + + vi_VN: typing.Optional[str] = None + """越南文""" + + es_ES: typing.Optional[str] = None + """西班牙文""" + + def to_dict(self) -> dict: + """转换为字典""" + dic = {} + if self.en_US is not None: + dic['en_US'] = self.en_US + if self.zh_Hans is not None: + dic['zh_Hans'] = self.zh_Hans + if self.zh_Hant is not None: + dic['zh_Hant'] = self.zh_Hant + if self.ja_JP is not None: + dic['ja_JP'] = self.ja_JP + if self.th_TH is not None: + dic['th_TH'] = self.th_TH + if self.vi_VN is not None: + dic['vi_VN'] = self.vi_VN + if self.es_ES is not None: + dic['es_ES'] = self.es_ES + return dic + + +class Metadata(pydantic.BaseModel): + """元数据""" + + name: str + """名称""" + + label: I18nString + """标签""" + + description: typing.Optional[I18nString] = None + """描述""" + + version: typing.Optional[str] = None + """版本""" + + icon: typing.Optional[str] = None + """图标""" + + author: typing.Optional[str] = None + """作者""" + + repository: typing.Optional[str] = None + """仓库""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + if self.description is None: + self.description = I18nString(en_US='') + + if self.icon is None: + self.icon = '' + + +class PythonExecution(pydantic.BaseModel): + """Python执行""" + + path: str + """路径""" + + attr: str + """属性""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + if self.path.startswith('./'): + self.path = self.path[2:] + + +class Execution(pydantic.BaseModel): + """执行""" + + python: PythonExecution + """Python执行""" + + +class Component(pydantic.BaseModel): + """组件清单""" + + owner: str + """组件所属""" + + manifest: typing.Dict[str, typing.Any] + """组件清单内容""" + + rel_path: str + """组件清单相对main.py的路径""" + + rel_dir: str + """组件清单相对main.py的目录""" + + _metadata: Metadata + """组件元数据""" + + _spec: typing.Dict[str, typing.Any] + """组件规格""" + + _execution: Execution + """组件执行""" + + def __init__(self, owner: str, manifest: typing.Dict[str, typing.Any], rel_path: str): + super().__init__( + owner=owner, + manifest=manifest, + rel_path=rel_path, + rel_dir=os.path.dirname(rel_path), + ) + self._metadata = Metadata(**manifest['metadata']) + self._spec = manifest['spec'] + self._execution = Execution(**manifest['execution']) if 'execution' in manifest else None + + @classmethod + def is_component_manifest(cls, manifest: typing.Dict[str, typing.Any]) -> bool: + """判断是否为组件清单""" + return 'apiVersion' in manifest and 'kind' in manifest and 'metadata' in manifest and 'spec' in manifest + + @property + def kind(self) -> str: + """组件类型""" + return self.manifest['kind'] + + @property + def metadata(self) -> Metadata: + """组件元数据""" + return self._metadata + + @property + def spec(self) -> typing.Dict[str, typing.Any]: + """组件规格""" + return self._spec + + @property + def execution(self) -> Execution: + """组件可执行文件信息""" + return self._execution + + @property + def icon_rel_path(self) -> str: + """图标相对路径""" + return ( + os.path.join(self.rel_dir, self.metadata.icon) + if self.metadata.icon is not None and self.metadata.icon.strip() != '' + else None + ) + + def get_python_component_class(self) -> typing.Type[typing.Any]: + """获取Python组件类""" + module_path = os.path.join(self.rel_dir, self.execution.python.path) + if module_path.endswith('.py'): + module_path = module_path[:-3] + module_path = module_path.replace('/', '.').replace('\\', '.') + module = importlib.import_module(f'langbot.{module_path}') + return getattr(module, self.execution.python.attr) + + def to_plain_dict(self) -> dict: + """转换为平铺字典""" + return { + 'name': self.metadata.name, + 'label': self.metadata.label.to_dict(), + 'description': self.metadata.description.to_dict(), + 'icon': self.metadata.icon, + 'spec': self.spec, + } + + +class ComponentDiscoveryEngine: + """组件发现引擎""" + + ap: app.Application + """应用实例""" + + components: typing.Dict[str, typing.List[Component]] = {} + """组件列表""" + + def __init__(self, ap: app.Application): + self.ap = ap + + def load_component_manifest(self, path: str, owner: str = 'builtin', no_save: bool = False) -> Component | None: + """加载组件清单""" + # with open(path, 'r', encoding='utf-8') as f: + # manifest = yaml.safe_load(f) + manifest = yaml.safe_load(importutil.read_resource_file(path)) + if not Component.is_component_manifest(manifest): + return None + comp = Component(owner=owner, manifest=manifest, rel_path=path) + if not no_save: + if comp.kind not in self.components: + self.components[comp.kind] = [] + self.components[comp.kind].append(comp) + return comp + + def load_component_manifests_in_dir( + self, + path: str, + owner: str = 'builtin', + no_save: bool = False, + max_depth: int = 1, + ) -> typing.List[Component]: + """加载目录中的组件清单""" + components: typing.List[Component] = [] + + def recursive_load_component_manifests_in_dir(path: str, depth: int = 1): + if depth > max_depth: + return + + for file in importutil.list_resource_files(path): + if (not os.path.isdir(os.path.join(path, file))) and (file.endswith('.yaml') or file.endswith('.yml')): + comp = self.load_component_manifest(os.path.join(path, file), owner, no_save) + if comp is not None: + components.append(comp) + elif os.path.isdir(os.path.join(path, file)): + recursive_load_component_manifests_in_dir(os.path.join(path, file), depth + 1) + + recursive_load_component_manifests_in_dir(path) + return components + + def load_blueprint_comp_group( + self, group: dict, owner: str = 'builtin', no_save: bool = False + ) -> typing.List[Component]: + """加载蓝图组件组""" + components: typing.List[Component] = [] + if 'fromFiles' in group: + for file in group['fromFiles']: + comp = self.load_component_manifest(file, owner, no_save) + if comp is not None: + components.append(comp) + if 'fromDirs' in group: + for dir in group['fromDirs']: + path = dir['path'] + max_depth = dir['maxDepth'] if 'maxDepth' in dir else 1 + components.extend(self.load_component_manifests_in_dir(path, owner, no_save, max_depth)) + return components + + def discover_blueprint(self, blueprint_manifest_path: str, owner: str = 'builtin'): + """发现蓝图""" + blueprint_manifest = self.load_component_manifest(blueprint_manifest_path, owner, no_save=True) + if blueprint_manifest is None: + raise ValueError(f'Invalid blueprint manifest: {blueprint_manifest_path}') + assert blueprint_manifest.kind == 'Blueprint', '`Kind` must be `Blueprint`' + components: typing.Dict[str, typing.List[Component]] = {} + + # load ComponentTemplate first + if 'ComponentTemplate' in blueprint_manifest.spec['components']: + components['ComponentTemplate'] = self.load_blueprint_comp_group( + blueprint_manifest.spec['components']['ComponentTemplate'], owner + ) + + for name, component in blueprint_manifest.spec['components'].items(): + if name == 'ComponentTemplate': + continue + components[name] = self.load_blueprint_comp_group(component, owner) + + self.ap.logger.debug(f'Components: {components}') + + return blueprint_manifest, components + + def get_components_by_kind(self, kind: str) -> typing.List[Component]: + """获取指定类型的组件""" + if kind not in self.components: + return [] + return self.components[kind] + + def find_components(self, kind: str, component_list: typing.List[Component]) -> typing.List[Component]: + """查找组件""" + result: typing.List[Component] = [] + for component in component_list: + if component.kind == kind: + result.append(component) + return result diff --git a/src/langbot/pkg/entity/__init__.py b/src/langbot/pkg/entity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/entity/dto/__init__.py b/src/langbot/pkg/entity/dto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/entity/dto/space_model.py b/src/langbot/pkg/entity/dto/space_model.py new file mode 100644 index 0000000..62b9f2b --- /dev/null +++ b/src/langbot/pkg/entity/dto/space_model.py @@ -0,0 +1,49 @@ +# [ +# { +# "uuid": "7652ebdb-54dc-412c-a830-e9268ac88471", +# "model_id": "claude-opus-4-5-20251101", +# "display_name": { +# "en_US": "claude-opus-4-5-20251101", +# "zh_Hans": "claude-opus-4-5-20251101" +# }, +# "description": {}, +# "provider": "anthropic", +# "category": "chat", +# "icon_url": "Claude.Color", +# "tags": {}, +# "is_featured": true, +# "featured_order": 999, +# "model_ratio": 2.5, +# "completion_ratio": 5, +# "quota_type": 0, +# "model_price": 0, +# "input_credits": 500, +# "output_credits": 2500, +# "vendor_id": 1, +# "vendor_name": "Anthropic", +# "vendor_icon": "Claude.Color", +# "supported_endpoints": [ +# "anthropic", +# "openai" +# ], +# "status": "active", +# "metadata": null, +# "created_at": "2025-12-30T22:23:38.337207+08:00", +# "updated_at": "2025-12-30T22:23:38.337207+08:00" +# } +# ] + +import pydantic + + +class SpaceModel(pydantic.BaseModel): + uuid: str + model_id: str + provider: str + category: str # chat / embedding + llm_abilities: list[str] | None = None + is_featured: bool = False + featured_order: int = 0 + status: str + created_at: str | None = None + updated_at: str | None = None diff --git a/src/langbot/pkg/entity/errors/__init__.py b/src/langbot/pkg/entity/errors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/entity/errors/account.py b/src/langbot/pkg/entity/errors/account.py new file mode 100644 index 0000000..edd5b41 --- /dev/null +++ b/src/langbot/pkg/entity/errors/account.py @@ -0,0 +1,6 @@ +from __future__ import annotations + + +class AccountEmailMismatchError(Exception): + def __str__(self): + return 'Account email mismatch' diff --git a/src/langbot/pkg/entity/errors/platform.py b/src/langbot/pkg/entity/errors/platform.py new file mode 100644 index 0000000..75fc229 --- /dev/null +++ b/src/langbot/pkg/entity/errors/platform.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +class AdapterNotFoundError(Exception): + def __init__(self, adapter_name: str): + self.adapter_name = adapter_name + + def __str__(self): + return f'Adapter {self.adapter_name} not found' diff --git a/src/langbot/pkg/entity/errors/provider.py b/src/langbot/pkg/entity/errors/provider.py new file mode 100644 index 0000000..c7831ce --- /dev/null +++ b/src/langbot/pkg/entity/errors/provider.py @@ -0,0 +1,17 @@ +from __future__ import annotations + + +class RequesterNotFoundError(Exception): + def __init__(self, requester_name: str): + self.requester_name = requester_name + + def __str__(self): + return f'Requester {self.requester_name} not found' + + +class ProviderNotFoundError(Exception): + def __init__(self, provider_name: str): + self.provider_name = provider_name + + def __str__(self): + return f'Provider {self.provider_name} not found' diff --git a/src/langbot/pkg/entity/persistence/__init__.py b/src/langbot/pkg/entity/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/entity/persistence/apikey.py b/src/langbot/pkg/entity/persistence/apikey.py new file mode 100644 index 0000000..488c032 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/apikey.py @@ -0,0 +1,21 @@ +import sqlalchemy + +from .base import Base + + +class ApiKey(Base): + """API Key for external service authentication""" + + __tablename__ = 'api_keys' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True) + description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='') + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/src/langbot/pkg/entity/persistence/base.py b/src/langbot/pkg/entity/persistence/base.py new file mode 100644 index 0000000..b0d8b5d --- /dev/null +++ b/src/langbot/pkg/entity/persistence/base.py @@ -0,0 +1,5 @@ +import sqlalchemy.orm + + +class Base(sqlalchemy.orm.DeclarativeBase): + pass diff --git a/src/langbot/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py new file mode 100644 index 0000000..9043b75 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/bot.py @@ -0,0 +1,40 @@ +import sqlalchemy + +from .base import Base + + +class BotAdmin(Base): + """Bot admin — a launcher that has admin privilege for a specific bot's commands""" + + __tablename__ = 'bot_admins' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False) + launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + + __table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),) + + +class Bot(Base): + """Bot""" + + __tablename__ = 'bots' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + adapter = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + adapter_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) + enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) + use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]') + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/src/langbot/pkg/entity/persistence/bstorage.py b/src/langbot/pkg/entity/persistence/bstorage.py new file mode 100644 index 0000000..674dee2 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/bstorage.py @@ -0,0 +1,22 @@ +import sqlalchemy + +from .base import Base + + +class BinaryStorage(Base): + """Current for plugin use only""" + + __tablename__ = 'binary_storages' + + unique_key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + owner_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + owner = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + value = sqlalchemy.Column(sqlalchemy.LargeBinary, nullable=False) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/src/langbot/pkg/entity/persistence/mcp.py b/src/langbot/pkg/entity/persistence/mcp.py new file mode 100644 index 0000000..983fdce --- /dev/null +++ b/src/langbot/pkg/entity/persistence/mcp.py @@ -0,0 +1,24 @@ +import sqlalchemy + +from .base import Base + + +class MCPServer(Base): + __tablename__ = 'mcp_servers' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) + mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, remote (legacy: sse, http) + extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) + # Markdown documentation captured from LangBot Space at install time so the + # detail page can show docs even when the server is offline / has no tools. + # Empty string for manually-created servers that have no marketplace README. + readme = sqlalchemy.Column(sqlalchemy.Text, nullable=False, server_default='', default='') + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/src/langbot/pkg/entity/persistence/metadata.py b/src/langbot/pkg/entity/persistence/metadata.py new file mode 100644 index 0000000..ac3b460 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/metadata.py @@ -0,0 +1,21 @@ +import sqlalchemy + +from .base import Base +from ...utils import constants + + +initial_metadata = [ + { + 'key': 'database_version', + 'value': str(constants.required_database_version), + }, +] + + +class Metadata(Base): + """Database metadata""" + + __tablename__ = 'metadata' + + key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + value = sqlalchemy.Column(sqlalchemy.String(255)) diff --git a/src/langbot/pkg/entity/persistence/model.py b/src/langbot/pkg/entity/persistence/model.py new file mode 100644 index 0000000..5b5f1fe --- /dev/null +++ b/src/langbot/pkg/entity/persistence/model.py @@ -0,0 +1,81 @@ +import sqlalchemy + +from .base import Base + + +class ModelProvider(Base): + """Model provider""" + + __tablename__ = 'model_providers' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + base_url = sqlalchemy.Column(sqlalchemy.String(512), nullable=False) + api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[]) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) + + +class LLMModel(Base): + """LLM model""" + + __tablename__ = 'llm_models' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[]) + context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True) + extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) + prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) + + +class EmbeddingModel(Base): + """Embedding model""" + + __tablename__ = 'embedding_models' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) + prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) + + +class RerankModel(Base): + """Rerank model""" + + __tablename__ = 'rerank_models' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) + prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/src/langbot/pkg/entity/persistence/monitoring.py b/src/langbot/pkg/entity/persistence/monitoring.py new file mode 100644 index 0000000..f594b81 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/monitoring.py @@ -0,0 +1,153 @@ +import sqlalchemy + +from .base import Base + + +class MonitoringMessage(Base): + """Monitoring message records""" + + __tablename__ = 'monitoring_messages' + + id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + message_content = sqlalchemy.Column(sqlalchemy.Text, nullable=False) + session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error, pending + level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug + platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name + runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query + variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string + role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant + + +class MonitoringLLMCall(Base): + """LLM call records""" + + __tablename__ = 'monitoring_llm_calls' + + id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) + model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + input_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + output_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + total_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds + cost = sqlalchemy.Column(sqlalchemy.Float, nullable=True) + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID + + +class MonitoringToolCall(Base): + """Tool call records""" + + __tablename__ = 'monitoring_tool_calls' + + id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) + tool_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + tool_source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # native, plugin, mcp, skill + duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + arguments = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + result = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + + +class MonitoringSession(Base): + """Session tracking records""" + + __tablename__ = 'monitoring_sessions' + + session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + message_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) + last_activity = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) + is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True) + platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name + + +class MonitoringError(Base): + """Error log records""" + + __tablename__ = 'monitoring_errors' + + id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) + error_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=False) + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + stack_trace = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID + + +class MonitoringEmbeddingCall(Base): + """Embedding call records""" + + __tablename__ = 'monitoring_embedding_calls' + + id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) + model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + prompt_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + total_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) + duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds + input_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # Number of input texts + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error + error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + # Optional context fields + knowledge_base_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + query_text = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # For retrieval calls + session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve + + +class MonitoringFeedback(Base): + """User feedback records (like/dislike) from AI Bot conversations""" + + __tablename__ = 'monitoring_feedback' + + id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) + feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True) + feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike + feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text + inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons + # Context fields + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom diff --git a/src/langbot/pkg/entity/persistence/pipeline.py b/src/langbot/pkg/entity/persistence/pipeline.py new file mode 100644 index 0000000..d74cf78 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/pipeline.py @@ -0,0 +1,58 @@ +import sqlalchemy + +from .base import Base + + +class LegacyPipeline(Base): + """Legacy pipeline""" + + __tablename__ = 'legacy_pipelines' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='⚙️') + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) + for_version = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + is_default = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) + stages = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) + config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) + extensions_preferences = sqlalchemy.Column( + sqlalchemy.JSON, + nullable=False, + default={ + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, + }, + ) + + +class PipelineRunRecord(Base): + """Pipeline run record""" + + __tablename__ = 'pipeline_run_records' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + status = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) + started_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False) + finished_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False) + result = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) + knowledge_base_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) diff --git a/src/langbot/pkg/entity/persistence/plugin.py b/src/langbot/pkg/entity/persistence/plugin.py new file mode 100644 index 0000000..6162958 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/plugin.py @@ -0,0 +1,24 @@ +import sqlalchemy + +from .base import Base + + +class PluginSetting(Base): + """Plugin setting""" + + __tablename__ = 'plugin_settings' + + plugin_author = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + plugin_name = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True) + priority = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict) + install_source = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, default='github') + install_info = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/src/langbot/pkg/entity/persistence/rag.py b/src/langbot/pkg/entity/persistence/rag.py new file mode 100644 index 0000000..cfb1f0a --- /dev/null +++ b/src/langbot/pkg/entity/persistence/rag.py @@ -0,0 +1,44 @@ +import sqlalchemy +from .base import Base + + +class KnowledgeBase(Base): + __tablename__ = 'knowledge_bases' + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String, index=True) + description = sqlalchemy.Column(sqlalchemy.Text) + emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='📚') + created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now()) + # New fields for plugin-based RAG + knowledge_engine_plugin_id = sqlalchemy.Column(sqlalchemy.String, nullable=True) + collection_id = sqlalchemy.Column(sqlalchemy.String, nullable=True) + creation_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None) + retrieval_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None) + + # Field sets for different operations + MUTABLE_FIELDS = {'name', 'description', 'retrieval_settings'} + """Fields that can be updated after creation.""" + + CREATE_FIELDS = MUTABLE_FIELDS | {'uuid', 'knowledge_engine_plugin_id', 'collection_id', 'creation_settings'} + """Fields used when creating a new knowledge base.""" + + ALL_DB_FIELDS = CREATE_FIELDS | {'emoji', 'created_at', 'updated_at'} + """All fields stored in database (for loading from DB row).""" + + +class File(Base): + __tablename__ = 'knowledge_base_files' + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + kb_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + file_name = sqlalchemy.Column(sqlalchemy.String) + extension = sqlalchemy.Column(sqlalchemy.String) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now()) + status = sqlalchemy.Column(sqlalchemy.String, default='pending') # pending, processing, completed, failed + + +class Chunk(Base): + __tablename__ = 'knowledge_base_chunks' + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + file_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + text = sqlalchemy.Column(sqlalchemy.Text) diff --git a/src/langbot/pkg/entity/persistence/user.py b/src/langbot/pkg/entity/persistence/user.py new file mode 100644 index 0000000..00ea738 --- /dev/null +++ b/src/langbot/pkg/entity/persistence/user.py @@ -0,0 +1,29 @@ +import sqlalchemy + +from .base import Base + + +class User(Base): + __tablename__ = 'users' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + + # Account type: 'local' (default) or 'space' + account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local') + + # Space account fields (nullable, only used when account_type='space') + space_account_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + space_access_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + space_refresh_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + space_access_token_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + space_api_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/src/langbot/pkg/entity/persistence/vector.py b/src/langbot/pkg/entity/persistence/vector.py new file mode 100644 index 0000000..465125f --- /dev/null +++ b/src/langbot/pkg/entity/persistence/vector.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, ForeignKey, LargeBinary +from sqlalchemy.orm import declarative_base, relationship + +Base = declarative_base() + + +class Vector(Base): + __tablename__ = 'vectors' + id = Column(Integer, primary_key=True, index=True) + chunk_id = Column(Integer, ForeignKey('chunks.id'), unique=True) + embedding = Column(LargeBinary) # Store embeddings as binary + + chunk = relationship('Chunk', back_populates='vector') diff --git a/src/langbot/pkg/entity/persistence/webhook.py b/src/langbot/pkg/entity/persistence/webhook.py new file mode 100644 index 0000000..326ab6c --- /dev/null +++ b/src/langbot/pkg/entity/persistence/webhook.py @@ -0,0 +1,22 @@ +import sqlalchemy + +from .base import Base + + +class Webhook(Base): + """Webhook for pushing bot events to external systems""" + + __tablename__ = 'webhooks' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + url = sqlalchemy.Column(sqlalchemy.String(1024), nullable=False) + description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='') + enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/src/langbot/pkg/persistence/__init__.py b/src/langbot/pkg/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/persistence/alembic/__init__.py b/src/langbot/pkg/persistence/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/persistence/alembic/env.py b/src/langbot/pkg/persistence/alembic/env.py new file mode 100644 index 0000000..40543ed --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/env.py @@ -0,0 +1,51 @@ +"""Alembic environment for LangBot. + +This env.py is designed to be called programmatically (not via CLI). +It supports both SQLite and PostgreSQL. + +The sync connection is passed via config attributes by the runner. +""" + +from __future__ import annotations + +from alembic import context +from sqlalchemy.engine import Connection + +from langbot.pkg.entity.persistence.base import Base + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode — emit SQL without a live connection.""" + url = context.config.get_main_option('sqlalchemy.url') + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={'paramstyle': 'named'}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations with a live sync connection passed via config attributes.""" + connection: Connection = context.config.attributes.get('connection') + if connection is None: + raise RuntimeError('connection not provided in alembic config attributes') + + context.configure( + connection=connection, + target_metadata=target_metadata, + # render_as_batch=True is critical for SQLite ALTER TABLE support + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/langbot/pkg/persistence/alembic/script.py.mako b/src/langbot/pkg/persistence/alembic/script.py.mako new file mode 100644 index 0000000..68feb3e --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/script.py.mako @@ -0,0 +1,24 @@ +# Alembic script.py.mako — template for auto-generated revisions +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py b/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py new file mode 100644 index 0000000..929356e --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py @@ -0,0 +1,24 @@ +"""baseline: stamp existing schema (db version 25) + +This is a no-op migration that marks the starting point for Alembic. +All tables already exist via create_all() + legacy DBMigration system. + +Revision ID: 0001_baseline +Revises: None +Create Date: 2026-04-08 +""" + +revision = '0001_baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # No-op: existing schema is already at database_version=25 + # This revision serves as the Alembic baseline. + pass + + +def downgrade() -> None: + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0002_sample.py b/src/langbot/pkg/persistence/alembic/versions/0002_sample.py new file mode 100644 index 0000000..9d869c1 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0002_sample.py @@ -0,0 +1,62 @@ +"""example: sample migration demonstrating Alembic patterns + +This is a SAMPLE showing how to write migrations that work +seamlessly across SQLite and PostgreSQL. Delete or adapt as needed. + +Revision ID: 0002_sample +Revises: 0001_baseline +Create Date: 2026-04-08 + +Patterns demonstrated: + 1. Schema change (add column) — works on both DBs via render_as_batch + 2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching +""" + +revision = '0002_sample' +down_revision = '0001_baseline' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """ + EXAMPLE: Uncomment to use. This shows the patterns. + + # --- Pattern 1: Schema change (add/drop column) --- + # render_as_batch=True in env.py makes this work on SQLite too. + # + # op.add_column('pipelines', sa.Column('description', sa.String(512), server_default='')) + + # --- Pattern 2: Data migration (read + modify JSON field) --- + # No if/else for sqlite vs postgres needed! + # + # conn = op.get_bind() + # rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall() + # for row in rows: + # config = json.loads(row[1]) if isinstance(row[1], str) else row[1] + # # Modify the config + # config.setdefault('ai', {}).setdefault('some_new_key', 'default_value') + # conn.execute( + # sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"), + # {"cfg": json.dumps(config), "uuid": row[0]} + # ) + + # --- Pattern 3: Create a new table --- + # + # op.create_table( + # 'audit_log', + # sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + # sa.Column('action', sa.String(255), nullable=False), + # sa.Column('detail', sa.Text), + # sa.Column('created_at', sa.DateTime, server_default=sa.func.now()), + # ) + """ + pass + + +def downgrade() -> None: + """ + # op.drop_column('pipelines', 'description') + # op.drop_table('audit_log') + """ + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0003_add_rerank_models.py b/src/langbot/pkg/persistence/alembic/versions/0003_add_rerank_models.py new file mode 100644 index 0000000..6070ddc --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0003_add_rerank_models.py @@ -0,0 +1,35 @@ +"""add rerank_models table + +Revision ID: 0003_add_rerank_models +Revises: 0002_sample +Create Date: 2026-04-19 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0003_add_rerank_models' +down_revision = '0002_sample' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Check if table already exists (may have been created by create_all()) + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'rerank_models' not in inspector.get_table_names(): + op.create_table( + 'rerank_models', + sa.Column('uuid', sa.String(255), primary_key=True, unique=True), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('provider_uuid', sa.String(255), nullable=False), + sa.Column('extra_args', sa.JSON, nullable=False, server_default='{}'), + sa.Column('prefered_ranking', sa.Integer, nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table('rerank_models') diff --git a/src/langbot/pkg/persistence/alembic/versions/0004_add_mcp_readme.py b/src/langbot/pkg/persistence/alembic/versions/0004_add_mcp_readme.py new file mode 100644 index 0000000..c845ddd --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0004_add_mcp_readme.py @@ -0,0 +1,34 @@ +"""add readme column to mcp_servers + +Revision ID: 0004_add_mcp_readme +Revises: 0003_add_rerank_models +Create Date: 2026-06-06 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0004_add_mcp_readme' +down_revision = '0003_add_rerank_models' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add ``readme`` to mcp_servers if the table exists and the column is missing + # (the table may have been created by create_all() with the column already + # present on fresh installs, so guard against duplicate-add). + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'mcp_servers' not in inspector.get_table_names(): + return + columns = {col['name'] for col in inspector.get_columns('mcp_servers')} + if 'readme' not in columns: + op.add_column( + 'mcp_servers', + sa.Column('readme', sa.Text(), nullable=False, server_default=''), + ) + + +def downgrade() -> None: + op.drop_column('mcp_servers', 'readme') diff --git a/src/langbot/pkg/persistence/alembic/versions/0005_add_llm_context_length.py b/src/langbot/pkg/persistence/alembic/versions/0005_add_llm_context_length.py new file mode 100644 index 0000000..20a9d71 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0005_add_llm_context_length.py @@ -0,0 +1,39 @@ +"""add llm model context length + +Revision ID: 0005_add_llm_context_length +Revises: 0004_add_mcp_readme +Create Date: 2026-06-07 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0005_add_llm_context_length' +down_revision = '0004_add_mcp_readme' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add ``context_length`` to llm_models if the table exists and the column is + # missing. The table may have been created by create_all() with the column + # already present on fresh installs, so guard against duplicate-add; it may + # also be absent entirely (e.g. migrating a truly empty DB), so guard against + # a missing table too. + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'llm_models' not in inspector.get_table_names(): + return + columns = {column['name'] for column in inspector.get_columns('llm_models')} + if 'context_length' not in columns: + op.add_column('llm_models', sa.Column('context_length', sa.Integer(), nullable=True)) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'llm_models' not in inspector.get_table_names(): + return + columns = {column['name'] for column in inspector.get_columns('llm_models')} + if 'context_length' in columns: + op.drop_column('llm_models', 'context_length') diff --git a/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py b/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py new file mode 100644 index 0000000..61a2669 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py @@ -0,0 +1,47 @@ +"""normalize mcp_servers transport mode to local/remote + +The MCP transport selection for servers LangBot connects to was simplified +from three persisted modes (``stdio`` / ``sse`` / ``http``) down to two: +``stdio`` (local, Box-sandboxed) and ``remote`` (the runtime auto-detects +Streamable HTTP vs. legacy SSE from the URL). This migration rewrites any +existing ``sse`` / ``http`` rows to ``remote`` so the stored value matches the +new two-option UI. The connection args (url / headers / timeout / +ssereadtimeout) live in ``extra_args`` and are left untouched — the +auto-detecting remote transport consumes them regardless. + +Revision ID: 0006_normalize_mcp_remote_mode +Revises: 0005_add_llm_context_length +Create Date: 2026-06-21 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0006_normalize_mcp_remote_mode' +down_revision = '0005_add_llm_context_length' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Idempotent data migration: collapse legacy remote transports into the + # unified ``remote`` mode. Guard against the table being absent (truly empty + # DB migrated before create_all()). + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'mcp_servers' not in inspector.get_table_names(): + return + conn.execute(sa.text("UPDATE mcp_servers SET mode = 'remote' WHERE mode IN ('sse', 'http')")) + + +def downgrade() -> None: + # The legacy distinction between ``sse`` and ``http`` cannot be recovered + # from ``remote`` alone (the transport is auto-detected at runtime, not + # stored). Map everything that is not ``stdio`` back to ``http`` as a + # best-effort reversal — both legacy modes still route correctly in the + # backend lifecycle dispatch. + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'mcp_servers' not in inspector.get_table_names(): + return + conn.execute(sa.text("UPDATE mcp_servers SET mode = 'http' WHERE mode = 'remote'")) diff --git a/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py new file mode 100644 index 0000000..a13f2ca --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py @@ -0,0 +1,84 @@ +"""add bot_admins table and migrate config admins + +Revision ID: 0007_add_bot_admins +Revises: 0006_normalize_mcp_remote_mode +Create Date: 2026-06-26 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0007_add_bot_admins' +down_revision = '0006_normalize_mcp_remote_mode' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + if 'bot_admins' in sa.inspect(conn).get_table_names(): + return + op.create_table( + 'bot_admins', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('bot_uuid', sa.String(255), nullable=False), + sa.Column('launcher_type', sa.String(64), nullable=False), + sa.Column('launcher_id', sa.String(255), nullable=False), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'), + ) + + # Migrate old config-based admins into the first bot (best-effort) + inspector = sa.inspect(conn) + tables = inspector.get_table_names() + + if 'bots' not in tables: + return + + # Read the first bot uuid + row = conn.execute(sa.text('SELECT uuid FROM bots ORDER BY created_at LIMIT 1')).first() + if row is None: + return + first_bot_uuid = row[0] + + # Read instance_config metadata key that holds the admins list + if 'metadata' not in tables: + return + meta_row = conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'instance_config'")).first() + if meta_row is None: + return + + import json + + try: + cfg = json.loads(meta_row[0]) + except Exception: + return + + admins = cfg.get('admins', []) + for entry in admins: + parts = entry.split('_', 1) + if len(parts) != 2: + continue + launcher_type, launcher_id = parts + try: + conn.execute( + sa.text( + 'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id) VALUES (:bu, :lt, :li)' + ), + {'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id}, + ) + except Exception: + pass + + # Remove admins key from stored config + if 'admins' in cfg: + del cfg['admins'] + conn.execute( + sa.text("UPDATE metadata SET value = :v WHERE key = 'instance_config'"), + {'v': json.dumps(cfg)}, + ) + + +def downgrade() -> None: + op.drop_table('bot_admins') diff --git a/src/langbot/pkg/persistence/alembic/versions/0008_add_mcp_resource_preferences.py b/src/langbot/pkg/persistence/alembic/versions/0008_add_mcp_resource_preferences.py new file mode 100644 index 0000000..9b92db9 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0008_add_mcp_resource_preferences.py @@ -0,0 +1,95 @@ +"""add mcp resource preferences to pipelines + +Revision ID: 0008_mcp_resource_prefs +Revises: 0007_add_bot_admins +Create Date: 2026-06-30 +""" + +from __future__ import annotations + +import json +from typing import Any + +import sqlalchemy as sa +from alembic import op + +revision = '0008_mcp_resource_prefs' +down_revision = '0007_add_bot_admins' +branch_labels = None +depends_on = None + + +_PIPELINE_TABLE = sa.table( + 'legacy_pipelines', + sa.column('uuid', sa.String(255)), + sa.column('extensions_preferences', sa.JSON()), +) + + +def _has_extensions_preferences_table(conn: sa.Connection) -> bool: + inspector = sa.inspect(conn) + if 'legacy_pipelines' not in inspector.get_table_names(): + return False + columns = {column['name'] for column in inspector.get_columns('legacy_pipelines')} + return 'extensions_preferences' in columns + + +def _decode_preferences(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return dict(value) + if isinstance(value, str): + try: + decoded = json.loads(value) + except json.JSONDecodeError: + return {} + if isinstance(decoded, dict): + return decoded + return {} + + +def _update_preferences(conn: sa.Connection, uuid: str, preferences: dict[str, Any]) -> None: + conn.execute( + _PIPELINE_TABLE.update().where(_PIPELINE_TABLE.c.uuid == uuid).values(extensions_preferences=preferences) + ) + + +def upgrade() -> None: + conn = op.get_bind() + if not _has_extensions_preferences_table(conn): + return + + rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all() + for uuid, raw_preferences in rows: + preferences = _decode_preferences(raw_preferences) + changed = False + + if 'mcp_resources' not in preferences: + preferences['mcp_resources'] = [] + changed = True + if 'mcp_resource_agent_read_enabled' not in preferences: + preferences['mcp_resource_agent_read_enabled'] = True + changed = True + + if changed: + _update_preferences(conn, uuid, preferences) + + +def downgrade() -> None: + conn = op.get_bind() + if not _has_extensions_preferences_table(conn): + return + + rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all() + for uuid, raw_preferences in rows: + preferences = _decode_preferences(raw_preferences) + changed = False + + for key in ('mcp_resources', 'mcp_resource_agent_read_enabled'): + if key in preferences: + preferences.pop(key) + changed = True + + if changed: + _update_preferences(conn, uuid, preferences) diff --git a/src/langbot/pkg/persistence/alembic/versions/__init__.py b/src/langbot/pkg/persistence/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/persistence/alembic_runner.py b/src/langbot/pkg/persistence/alembic_runner.py new file mode 100644 index 0000000..74c2bac --- /dev/null +++ b/src/langbot/pkg/persistence/alembic_runner.py @@ -0,0 +1,150 @@ +"""Programmatic Alembic runner for LangBot. + +Usage from async code: + from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade + await run_alembic_upgrade(async_engine) + +CLI usage (autogenerate): + python -m langbot.pkg.persistence.alembic_runner autogenerate "add description column" + python -m langbot.pkg.persistence.alembic_runner upgrade + python -m langbot.pkg.persistence.alembic_runner current +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from alembic.config import Config +from alembic import command +from alembic.runtime.migration import MigrationContext + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncEngine + from sqlalchemy.engine import Connection + + +_ALEMBIC_DIR = os.path.join(os.path.dirname(__file__), 'alembic') + + +def _build_config(connection: Connection) -> Config: + """Build an Alembic Config with sync connection attached.""" + cfg = Config() + cfg.set_main_option('script_location', _ALEMBIC_DIR) + cfg.attributes['connection'] = connection + return cfg + + +def _do_upgrade(connection: Connection, revision: str = 'head') -> None: + """Synchronous upgrade — runs inside run_sync.""" + cfg = _build_config(connection) + command.upgrade(cfg, revision) + + +def _do_stamp(connection: Connection, revision: str = 'head') -> None: + """Synchronous stamp — runs inside run_sync.""" + cfg = _build_config(connection) + command.stamp(cfg, revision) + + +def _do_get_current(connection: Connection) -> str | None: + """Get current alembic revision synchronously.""" + ctx = MigrationContext.configure(connection) + return ctx.get_current_revision() + + +def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None: + """Synchronous autogenerate — runs inside run_sync.""" + cfg = _build_config(connection) + command.revision(cfg, message=message, autogenerate=True) + + +async def run_alembic_upgrade(async_engine: AsyncEngine, revision: str = 'head') -> None: + """Run Alembic upgrade to the given revision.""" + async with async_engine.connect() as conn: + await conn.run_sync(_do_upgrade, revision) + await conn.commit() + + +async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -> None: + """Stamp the database with a revision without running migrations.""" + async with async_engine.connect() as conn: + await conn.run_sync(_do_stamp, revision) + await conn.commit() + + +async def get_alembic_current(async_engine: AsyncEngine) -> str | None: + """Get current alembic revision, or None if not stamped.""" + async with async_engine.connect() as conn: + return await conn.run_sync(_do_get_current) + + +async def run_alembic_autogenerate(async_engine: AsyncEngine, message: str = 'auto migration') -> None: + """Compare ORM models against DB schema and generate a migration script.""" + async with async_engine.connect() as conn: + await conn.run_sync(_do_autogenerate, message) + + +# CLI entrypoint: python -m langbot.pkg.persistence.alembic_runner [args] +if __name__ == '__main__': + import sys + import asyncio + + def _get_engine(): + """Create engine from data/config.yaml or default SQLite.""" + from sqlalchemy.ext.asyncio import create_async_engine + + try: + import yaml + + with open('data/config.yaml') as f: + config = yaml.safe_load(f) + db_cfg = config.get('database', {}) + db_type = db_cfg.get('use', 'sqlite') + if db_type == 'postgresql': + pg = db_cfg.get('postgresql', {}) + url = ( + f'postgresql+asyncpg://{pg.get("user", "postgres")}:{pg.get("password", "postgres")}' + f'@{pg.get("host", "127.0.0.1")}:{pg.get("port", 5432)}/{pg.get("database", "postgres")}' + ) + else: + path = db_cfg.get('sqlite', {}).get('path', 'data/langbot.db') + url = f'sqlite+aiosqlite:///{path}' + except Exception: + url = 'sqlite+aiosqlite:///data/langbot.db' + + return create_async_engine(url) + + def main(): + if len(sys.argv) < 2: + print('Usage: python -m langbot.pkg.persistence.alembic_runner [args]') + print('Commands:') + print(' autogenerate "message" — Generate migration from ORM model diff') + print(' upgrade [revision] — Upgrade database (default: head)') + print(' stamp [revision] — Stamp revision without running (default: head)') + print(' current — Show current revision') + sys.exit(1) + + cmd = sys.argv[1] + engine = _get_engine() + + if cmd == 'autogenerate': + msg = sys.argv[2] if len(sys.argv) > 2 else 'auto migration' + asyncio.run(run_alembic_autogenerate(engine, msg)) + print(f'Migration generated: {msg}') + elif cmd == 'upgrade': + rev = sys.argv[2] if len(sys.argv) > 2 else 'head' + asyncio.run(run_alembic_upgrade(engine, rev)) + print(f'Upgraded to: {rev}') + elif cmd == 'stamp': + rev = sys.argv[2] if len(sys.argv) > 2 else 'head' + asyncio.run(run_alembic_stamp(engine, rev)) + print(f'Stamped: {rev}') + elif cmd == 'current': + rev = asyncio.run(get_alembic_current(engine)) + print(f'Current revision: {rev}') + else: + print(f'Unknown command: {cmd}') + sys.exit(1) + + main() diff --git a/src/langbot/pkg/persistence/database.py b/src/langbot/pkg/persistence/database.py new file mode 100644 index 0000000..4debb03 --- /dev/null +++ b/src/langbot/pkg/persistence/database.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import abc + +import sqlalchemy.ext.asyncio as sqlalchemy_asyncio + +from ..core import app + + +preregistered_managers: list[type[BaseDatabaseManager]] = [] + + +def manager_class(name: str) -> None: + """Register a database manager class""" + + def decorator(cls: type[BaseDatabaseManager]) -> type[BaseDatabaseManager]: + cls.name = name + preregistered_managers.append(cls) + return cls + + return decorator + + +class BaseDatabaseManager(abc.ABC): + """Base database manager class""" + + name: str + + ap: app.Application + + engine: sqlalchemy_asyncio.AsyncEngine + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + @abc.abstractmethod + async def initialize(self) -> None: + pass + + def get_engine(self) -> sqlalchemy_asyncio.AsyncEngine: + return self.engine diff --git a/src/langbot/pkg/persistence/databases/__init__.py b/src/langbot/pkg/persistence/databases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/persistence/databases/postgresql.py b/src/langbot/pkg/persistence/databases/postgresql.py new file mode 100644 index 0000000..f63d8f6 --- /dev/null +++ b/src/langbot/pkg/persistence/databases/postgresql.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import sqlalchemy.ext.asyncio as sqlalchemy_asyncio + +from .. import database + + +@database.manager_class('postgresql') +class PostgreSQLDatabaseManager(database.BaseDatabaseManager): + """PostgreSQL database manager""" + + async def initialize(self) -> None: + postgresql_config = self.ap.instance_config.data.get('database', {}).get('postgresql', {}) + + host = postgresql_config.get('host', '127.0.0.1') + port = postgresql_config.get('port', 5432) + user = postgresql_config.get('user', 'postgres') + password = postgresql_config.get('password', 'postgres') + database = postgresql_config.get('database', 'postgres') + engine_url = f'postgresql+asyncpg://{user}:{password}@{host}:{port}/{database}' + self.engine = sqlalchemy_asyncio.create_async_engine(engine_url) diff --git a/src/langbot/pkg/persistence/databases/sqlite.py b/src/langbot/pkg/persistence/databases/sqlite.py new file mode 100644 index 0000000..ed09604 --- /dev/null +++ b/src/langbot/pkg/persistence/databases/sqlite.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import sqlalchemy.ext.asyncio as sqlalchemy_asyncio + +from .. import database + + +@database.manager_class('sqlite') +class SQLiteDatabaseManager(database.BaseDatabaseManager): + """SQLite database manager""" + + async def initialize(self) -> None: + db_file_path = self.ap.instance_config.data.get('database', {}).get('sqlite', {}).get('path', 'data/langbot.db') + engine_url = f'sqlite+aiosqlite:///{db_file_path}' + self.engine = sqlalchemy_asyncio.create_async_engine(engine_url) diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py new file mode 100644 index 0000000..7ad7b96 --- /dev/null +++ b/src/langbot/pkg/persistence/mgr.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import datetime +import typing + + +import sqlalchemy.ext.asyncio as sqlalchemy_asyncio +import sqlalchemy + +from . import database, migration +from ..entity.persistence import base, metadata, model as persistence_model +from ..entity import persistence +from ..core import app +from ..utils import constants, importutil +from . import databases, migrations + +importutil.import_modules_in_pkg(databases) +importutil.import_modules_in_pkg(migrations) +importutil.import_modules_in_pkg(persistence) + + +class PersistenceManager: + """Persistence module manager""" + + ap: app.Application + + db: database.BaseDatabaseManager + """Database manager""" + + meta: sqlalchemy.MetaData + + def __init__(self, ap: app.Application): + self.ap = ap + self.meta = base.Base.metadata + + async def initialize(self): + database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite') + self.ap.logger.info(f'Initializing database type: {database_type}...') + for manager in database.preregistered_managers: + if manager.name == database_type: + self.db = manager(self.ap) + await self.db.initialize() + break + + await self.create_tables() + + # run migrations + database_version = await self.execute_async( + sqlalchemy.select(metadata.Metadata).where(metadata.Metadata.key == 'database_version') + ) + + database_version = int(database_version.fetchone()[1]) + required_database_version = constants.required_database_version + + if database_version < required_database_version: + migrations = migration.preregistered_db_migrations + migrations.sort(key=lambda x: x.number) + + last_migration_number = database_version + + for migration_cls in migrations: + migration_instance = migration_cls(self.ap) + + if ( + migration_instance.number > database_version + and migration_instance.number <= required_database_version + ): + await migration_instance.upgrade() + await self.execute_async( + sqlalchemy.update(metadata.Metadata) + .where(metadata.Metadata.key == 'database_version') + .values({'value': str(migration_instance.number)}) + ) + last_migration_number = migration_instance.number + self.ap.logger.info(f'Migration {migration_instance.number} completed.') + + self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.') + + # Run Alembic migrations (new migration system) + await self._run_alembic_migrations() + + await self.write_space_model_providers() + + async def create_tables(self): + # create tables + async with self.get_db_engine().connect() as conn: + await conn.run_sync(self.meta.create_all) + + await conn.commit() + + # ======= write initial data ======= + + # write initial metadata + self.ap.logger.info('Creating initial metadata...') + for item in metadata.initial_metadata: + # check if the item exists + result = await self.execute_async( + sqlalchemy.select(metadata.Metadata).where(metadata.Metadata.key == item['key']) + ) + row = result.first() + if row is None: + await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item)) + + async def write_space_model_providers(self): + space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get( + 'models_gateway_api_url', 'https://api.langbot.cloud/v1' + ) + + # write space model providers + result = await self.execute_async( + sqlalchemy.select(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.requester == 'space-chat-completions' + ) + ) + exists_space_chat_completions_model_provider = result.first() + + # api keys will be set/updated when the oauth callback + if exists_space_chat_completions_model_provider is None: + self.ap.logger.info('Creating space model providers...') + space_chat_completions_model_provider = { + 'uuid': '00000000-0000-0000-0000-000000000000', + 'name': 'LangBot Models', + 'requester': 'space-chat-completions', + 'base_url': space_models_gateway_api_url, + 'api_keys': [], + } + + await self.execute_async( + sqlalchemy.insert(persistence_model.ModelProvider).values(space_chat_completions_model_provider) + ) + else: + if exists_space_chat_completions_model_provider.base_url != space_models_gateway_api_url: + await self.execute_async( + sqlalchemy.update(persistence_model.ModelProvider) + .where(persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid) + .values({'base_url': space_models_gateway_api_url}) + ) + + # ================================= + + async def _run_alembic_migrations(self): + """Run Alembic-based migrations after legacy migrations complete.""" + from . import alembic_runner + + engine = self.get_db_engine() + + try: + current_rev = await alembic_runner.get_alembic_current(engine) + + if current_rev is None: + # First time: stamp baseline so Alembic knows existing schema is up-to-date + self.ap.logger.info('Alembic: no revision found, stamping baseline...') + await alembic_runner.run_alembic_stamp(engine, '0001_baseline') + current_rev = '0001_baseline' + + # Upgrade to head + await alembic_runner.run_alembic_upgrade(engine, 'head') + self.ap.logger.info('Alembic migrations completed.') + except Exception as e: + self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True) + raise + + async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult: + async with self.get_db_engine().connect() as conn: + result = await conn.execute(*args, **kwargs) + await conn.commit() + return result + + def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine: + return self.db.get_engine() + + def serialize_model( + self, model: typing.Type[sqlalchemy.Base], data: sqlalchemy.Base, masked_columns: list[str] = [] + ) -> dict: + return { + column.name: getattr(data, column.name) + if not isinstance(getattr(data, column.name), (datetime.datetime)) + else getattr(data, column.name).isoformat() + for column in model.__table__.columns + if column.name not in masked_columns + } diff --git a/src/langbot/pkg/persistence/migration.py b/src/langbot/pkg/persistence/migration.py new file mode 100644 index 0000000..294e30c --- /dev/null +++ b/src/langbot/pkg/persistence/migration.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import typing +import abc + +from ..core import app + + +preregistered_db_migrations: list[typing.Type[DBMigration]] = [] + + +def migration_class(number: int): + """Migration class decorator""" + + def wrapper(cls: typing.Type[DBMigration]) -> typing.Type[DBMigration]: + cls.number = number + preregistered_db_migrations.append(cls) + return cls + + return wrapper + + +class DBMigration(abc.ABC): + """Database migration""" + + number: int + """Migration number""" + + def __init__(self, ap: app.Application): + self.ap = ap + + @abc.abstractmethod + async def upgrade(self): + """Upgrade""" + pass + + @abc.abstractmethod + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/README.md b/src/langbot/pkg/persistence/migrations/README.md new file mode 100644 index 0000000..8f62f1d --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/README.md @@ -0,0 +1,36 @@ +# Legacy migrations (DEPRECATED — do not add new files here) + +This directory holds the **legacy 3.x database migration system** +(`DBMigration` subclasses in `dbmXXX_*.py`, registered via +`@migration.migration_class(N)` and run from `pkg/persistence/mgr.py`). + +**This system is frozen. Do not add new `dbmXXX_*.py` migrations.** + +The chain is capped at version 25 (`required_database_version = 25` in +`pkg/utils/constants.py`). These files exist only to upgrade pre-existing +3.x databases up to the Alembic baseline (`0001_baseline`). Removing them +would break in-place upgrades from old installations, so they are kept +read-only. + +## All new schema changes use Alembic + +Migrations now live in `pkg/persistence/alembic/versions/`. To create one: + +```bash +uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change" +``` + +(requires `data/config.yaml` to exist). Review and edit the generated +script before committing — Alembic migrations run automatically on startup +and must be idempotent and guard against missing tables (the test suite +runs them against empty databases). + +### Rules for Alembic revision ids + +- Keep the revision id **≤ 32 characters** — PostgreSQL stores + `alembic_version.version_num` as `varchar(32)` and will raise + `StringDataRightTruncationError` on overflow. +- Guard every `op` call against a missing table / missing column + (`inspector.get_table_names()` / `inspector.get_columns()`); fresh + installs create the schema via `create_all()` and stamp the baseline, + so migrations may run against tables that already match or do not exist. diff --git a/src/langbot/pkg/persistence/migrations/__init__.py b/src/langbot/pkg/persistence/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py b/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py new file mode 100644 index 0000000..55e63ff --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py @@ -0,0 +1,252 @@ +from .. import migration +from copy import deepcopy +import uuid +import os +import sqlalchemy +import shutil + +from ...config import manager as config_manager +from ...entity.persistence import ( + model as persistence_model, + pipeline as persistence_pipeline, + bot as persistence_bot, +) + + +@migration.migration_class(1) +class DBMigrateV3Config(migration.DBMigration): + """Migrate v3 config to v4 database""" + + async def upgrade(self): + """Upgrade""" + """ + Migrate all config files under data/config. + After migration, all previous config files are saved under data/legacy/config. + After migration, all config files under data/metadata/ are saved under data/legacy/metadata. + """ + + if self.ap.provider_cfg is None: + return + + # ======= Migrate model ======= + # Only migrate the currently selected model + model_name = self.ap.provider_cfg.data.get('model', 'gpt-4o') + + model_requester = 'openai-chat-completions' + model_requester_config = {} + model_api_keys = ['sk-proj-1234567890'] + model_abilities = [] + model_extra_args = {} + + if os.path.exists('data/metadata/llm-models.json'): + _llm_model_meta = await config_manager.load_json_config('data/metadata/llm-models.json', completion=False) + + for item in _llm_model_meta.data.get('list', []): + if item.get('name') == model_name: + if 'model_name' in item: + model_name = item['model_name'] + if 'requester' in item: + model_requester = item['requester'] + if 'token_mgr' in item: + _token_mgr = item['token_mgr'] + + if _token_mgr in self.ap.provider_cfg.data.get('keys', {}): + model_api_keys = self.ap.provider_cfg.data.get('keys', {})[_token_mgr] + + if 'tool_call_supported' in item and item['tool_call_supported']: + model_abilities.append('func_call') + + if 'vision_supported' in item and item['vision_supported']: + model_abilities.append('vision') + + if ( + model_requester in self.ap.provider_cfg.data.get('requester', {}) + and 'args' in self.ap.provider_cfg.data.get('requester', {})[model_requester] + ): + model_extra_args = self.ap.provider_cfg.data.get('requester', {})[model_requester]['args'] + + if model_requester in self.ap.provider_cfg.data.get('requester', {}): + model_requester_config = self.ap.provider_cfg.data.get('requester', {})[model_requester] + model_requester_config = { + 'base_url': model_requester_config['base-url'], + 'timeout': model_requester_config['timeout'], + } + + break + + model_uuid = str(uuid.uuid4()) + + llm_model_data = { + 'uuid': model_uuid, + 'name': model_name, + 'description': '由 LangBot v3 迁移而来', + 'requester': model_requester, + 'requester_config': model_requester_config, + 'api_keys': model_api_keys, + 'abilities': model_abilities, + 'extra_args': model_extra_args, + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_model.LLMModel).values(**llm_model_data) + ) + + # ======= Migrate pipeline config ======= + # Modify to default pipeline + default_pipeline = [ + self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) + for pipeline in ( + await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( + persistence_pipeline.LegacyPipeline.is_default == True + ) + ) + ).all() + ][0] + + pipeline_uuid = str(uuid.uuid4()) + pipeline_name = 'ChatPipeline' + + if default_pipeline: + pipeline_name = default_pipeline['name'] + pipeline_uuid = default_pipeline['uuid'] + + pipeline_config = default_pipeline['config'] + + # ai + pipeline_config['ai']['runner'] = { + 'runner': self.ap.provider_cfg.data['runner'], + } + pipeline_config['ai']['local-agent']['model'] = model_uuid + pipeline_config['ai']['local-agent']['max-round'] = self.ap.pipeline_cfg.data['msg-truncate']['round'][ + 'max-round' + ] + + pipeline_config['ai']['local-agent']['prompt'] = [ + { + 'role': 'system', + 'content': self.ap.provider_cfg.data['prompt']['default'], + } + ] + pipeline_config['ai']['dify-service-api'] = { + 'base-url': self.ap.provider_cfg.data['dify-service-api']['base-url'], + 'app-type': self.ap.provider_cfg.data['dify-service-api']['app-type'], + 'api-key': self.ap.provider_cfg.data['dify-service-api'][ + self.ap.provider_cfg.data['dify-service-api']['app-type'] + ]['api-key'], + 'thinking-convert': self.ap.provider_cfg.data['dify-service-api']['options']['convert-thinking-tips'], + 'timeout': self.ap.provider_cfg.data['dify-service-api'][ + self.ap.provider_cfg.data['dify-service-api']['app-type'] + ]['timeout'], + } + pipeline_config['ai']['dashscope-app-api'] = { + 'app-type': self.ap.provider_cfg.data['dashscope-app-api']['app-type'], + 'api-key': self.ap.provider_cfg.data['dashscope-app-api']['api-key'], + 'references_quote': self.ap.provider_cfg.data['dashscope-app-api'][ + self.ap.provider_cfg.data['dashscope-app-api']['app-type'] + ]['references_quote'], + } + + # trigger + pipeline_config['trigger']['group-respond-rules'] = self.ap.pipeline_cfg.data['respond-rules']['default'] + pipeline_config['trigger']['access-control'] = self.ap.pipeline_cfg.data['access-control'] + pipeline_config['trigger']['ignore-rules'] = self.ap.pipeline_cfg.data['ignore-rules'] + + # safety + pipeline_config['safety']['content-filter'] = { + 'scope': 'all', + 'check-sensitive-words': self.ap.pipeline_cfg.data['check-sensitive-words'], + } + pipeline_config['safety']['rate-limit'] = { + 'window-length': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['window-size'], + 'limitation': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['limit'], + 'strategy': self.ap.pipeline_cfg.data['rate-limit']['strategy'], + } + + # output + pipeline_config['output']['long-text-processing'] = self.ap.platform_cfg.data['long-text-process'] + pipeline_config['output']['force-delay'] = self.ap.platform_cfg.data['force-delay'] + pipeline_config['output']['misc'] = { + 'hide-exception': self.ap.platform_cfg.data['hide-exception-info'], + 'quote-origin': self.ap.platform_cfg.data['quote-origin'], + 'at-sender': self.ap.platform_cfg.data['at-sender'], + 'track-function-calls': self.ap.platform_cfg.data['track-function-calls'], + } + + default_pipeline['description'] = default_pipeline['description'] + ' [已迁移 LangBot v3 配置]' + default_pipeline['config'] = pipeline_config + default_pipeline.pop('created_at') + default_pipeline.pop('updated_at') + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_pipeline.LegacyPipeline) + .values(default_pipeline) + .where(persistence_pipeline.LegacyPipeline.uuid == default_pipeline['uuid']) + ) + + # ======= Migrate bot ======= + # Only migrate enabled bots + for adapter in self.ap.platform_cfg.data.get('platform-adapters', []): + if not adapter.get('enable'): + continue + + args = deepcopy(adapter) + args.pop('adapter') + args.pop('enable') + + bot_data = { + 'uuid': str(uuid.uuid4()), + 'name': adapter.get('adapter'), + 'description': '由 LangBot v3 迁移而来', + 'adapter': adapter.get('adapter'), + 'adapter_config': args, + 'enable': True, + 'use_pipeline_uuid': pipeline_uuid, + 'use_pipeline_name': pipeline_name, + } + + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(**bot_data)) + + # ======= Migrate system settings ======= + self.ap.instance_config.data['admins'] = self.ap.system_cfg.data['admin-sessions'] + self.ap.instance_config.data['api']['port'] = self.ap.system_cfg.data['http-api']['port'] + self.ap.instance_config.data['command'] = { + 'prefix': self.ap.command_cfg.data['command-prefix'], + 'enable': self.ap.command_cfg.data['command-enable'] + if 'command-enable' in self.ap.command_cfg.data + else True, + 'privilege': self.ap.command_cfg.data['privilege'], + } + self.ap.instance_config.data['concurrency']['pipeline'] = self.ap.system_cfg.data['pipeline-concurrency'] + self.ap.instance_config.data['concurrency']['session'] = self.ap.system_cfg.data['session-concurrency'][ + 'default' + ] + self.ap.instance_config.data['mcp'] = self.ap.provider_cfg.data['mcp'] + self.ap.instance_config.data['proxy'] = self.ap.system_cfg.data['network-proxies'] + await self.ap.instance_config.dump_config() + + # ======= move files ======= + # Migrate all config files under data/config + all_legacy_dir_name = [ + 'config', + # 'metadata', + 'prompts', + 'scenario', + ] + + def move_legacy_files(dir_name: str): + if not os.path.exists(f'data/legacy/{dir_name}'): + os.makedirs(f'data/legacy/{dir_name}') + + if os.path.exists(f'data/{dir_name}'): + for file in os.listdir(f'data/{dir_name}'): + if file.endswith('.json'): + shutil.move(f'data/{dir_name}/{file}', f'data/legacy/{dir_name}/{file}') + + os.rmdir(f'data/{dir_name}') + + for dir_name in all_legacy_dir_name: + move_legacy_files(dir_name) + + async def downgrade(self): + """Downgrade""" diff --git a/src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py b/src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py new file mode 100644 index 0000000..471e28c --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py @@ -0,0 +1,55 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(2) +class DBMigrateCombineQuoteMsgConfig(migration.DBMigration): + """Combine quote message config""" + + async def upgrade(self): + """Upgrade""" + # Read all pipelines using raw SQL + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + # Ensure 'trigger' exists + if 'trigger' not in config: + config['trigger'] = {} + + # Ensure 'misc' exists in 'trigger' + if 'misc' not in config['trigger']: + config['trigger']['misc'] = {} + + # Add 'combine-quote-message' if not exists + if 'combine-quote-message' not in config['trigger']['misc']: + config['trigger']['misc']['combine-quote-message'] = False + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py b/src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py new file mode 100644 index 0000000..602562c --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py @@ -0,0 +1,62 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(3) +class DBMigrateN8nConfig(migration.DBMigration): + """N8n config""" + + async def upgrade(self): + """Upgrade""" + # Read all pipelines using raw SQL + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + # Ensure 'ai' exists + if 'ai' not in config: + config['ai'] = {} + + # Add 'n8n-service-api' if not exists + if 'n8n-service-api' not in config['ai']: + config['ai']['n8n-service-api'] = { + 'webhook-url': 'http://your-n8n-webhook-url', + 'auth-type': 'none', + 'basic-username': '', + 'basic-password': '', + 'jwt-secret': '', + 'jwt-algorithm': 'HS256', + 'header-name': '', + 'header-value': '', + 'timeout': 120, + 'output-key': 'response', + } + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py b/src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py new file mode 100644 index 0000000..d422102 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py @@ -0,0 +1,53 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(4) +class DBMigrateRAGKBUUID(migration.DBMigration): + """RAG知识库UUID""" + + async def upgrade(self): + """升级""" + # Read all pipelines using raw SQL + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + # Ensure nested structure exists + if 'ai' not in config: + config['ai'] = {} + if 'local-agent' not in config['ai']: + config['ai']['local-agent'] = {} + + # Add 'knowledge-base' if not exists + if 'knowledge-base' not in config['ai']['local-agent']: + config['ai']['local-agent']['knowledge-base'] = '' + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + + async def downgrade(self): + """降级""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py b/src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py new file mode 100644 index 0000000..7e71bb0 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py @@ -0,0 +1,53 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(5) +class DBMigratePipelineRemoveCotConfig(migration.DBMigration): + """Pipeline remove cot config""" + + async def upgrade(self): + """Upgrade""" + # Read all pipelines using raw SQL + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + # Ensure nested structure exists + if 'output' not in config: + config['output'] = {} + if 'misc' not in config['output']: + config['output']['misc'] = {} + + # Add 'remove-think' if not exists + if 'remove-think' not in config['output']['misc']: + config['output']['misc']['remove-think'] = False + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py b/src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py new file mode 100644 index 0000000..d25e987 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py @@ -0,0 +1,58 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(6) +class DBMigrateLangflowApiConfig(migration.DBMigration): + """Langflow API config""" + + async def upgrade(self): + """Upgrade""" + # Read all pipelines using raw SQL + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + # Ensure 'ai' exists + if 'ai' not in config: + config['ai'] = {} + + # Add 'langflow-api' if not exists + if 'langflow-api' not in config['ai']: + config['ai']['langflow-api'] = { + 'base-url': 'http://localhost:7860', + 'api-key': 'your-api-key', + 'flow-id': 'your-flow-id', + 'input-type': 'chat', + 'output-type': 'chat', + 'tweaks': '{}', + } + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py b/src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py new file mode 100644 index 0000000..26c38c5 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py @@ -0,0 +1,44 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(7) +class DBMigratePluginInstallSource(migration.DBMigration): + """插件安装来源""" + + async def upgrade(self): + """升级""" + # 查询表结构获取所有列名(异步执行 SQL) + + columns = [] + + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + "SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_settings';" + ) + ) + all_result = result.fetchall() + columns = [row[0] for row in all_result] + else: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(plugin_settings);')) + all_result = result.fetchall() + columns = [row[1] for row in all_result] + + # 检查并添加 install_source 列 + if 'install_source' not in columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + "ALTER TABLE plugin_settings ADD COLUMN install_source VARCHAR(255) NOT NULL DEFAULT 'github'" + ) + ) + + # 检查并添加 install_info 列 + if 'install_info' not in columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("ALTER TABLE plugin_settings ADD COLUMN install_info JSON NOT NULL DEFAULT '{}'") + ) + + async def downgrade(self): + """降级""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py b/src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py new file mode 100644 index 0000000..23da356 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py @@ -0,0 +1,22 @@ +from .. import migration + + +@migration.migration_class(8) +class DBMigratePluginConfig(migration.DBMigration): + """插件配置""" + + async def upgrade(self): + """升级""" + + if 'plugin' not in self.ap.instance_config.data: + self.ap.instance_config.data['plugin'] = { + 'runtime_ws_url': 'ws://langbot_plugin_runtime:5400/control/ws', + 'enable_marketplace': True, + 'cloud_service_url': 'https://space.langbot.app', + } + + await self.ap.instance_config.dump_config() + + async def downgrade(self): + """降级""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py b/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py new file mode 100644 index 0000000..927da4b --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py @@ -0,0 +1,20 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(9) +class DBMigratePipelineExtensionPreferences(migration.DBMigration): + """Pipeline extension preferences""" + + async def upgrade(self): + """Upgrade""" + + sql_text = sqlalchemy.text( + "ALTER TABLE legacy_pipelines ADD COLUMN extensions_preferences JSON NOT NULL DEFAULT '{}'" + ) + await self.ap.persistence_mgr.execute_async(sql_text) + + async def downgrade(self): + """Downgrade""" + sql_text = sqlalchemy.text('ALTER TABLE legacy_pipelines DROP COLUMN extensions_preferences') + await self.ap.persistence_mgr.execute_async(sql_text) diff --git a/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py b/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py new file mode 100644 index 0000000..fcbe149 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py @@ -0,0 +1,105 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(10) +class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration): + """Pipeline support multiple knowledge base binding""" + + async def upgrade(self): + """Upgrade""" + # Read all pipelines using raw SQL + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + # Convert knowledge-base from string to array + if 'ai' in config and 'local-agent' in config['ai']: + current_kb = config['ai']['local-agent'].get('knowledge-base', '') + + # If it's already a list, skip + if isinstance(current_kb, list): + continue + + # Convert string to list + if current_kb and current_kb != '__none__': + config['ai']['local-agent']['knowledge-bases'] = [current_kb] + else: + config['ai']['local-agent']['knowledge-bases'] = [] + + # Remove old field + if 'knowledge-base' in config['ai']['local-agent']: + del config['ai']['local-agent']['knowledge-base'] + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + + async def downgrade(self): + """Downgrade""" + # Read all pipelines using raw SQL + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + # Convert knowledge-bases from array back to string + if 'ai' in config and 'local-agent' in config['ai']: + current_kbs = config['ai']['local-agent'].get('knowledge-bases', []) + + # If it's already a string, skip + if isinstance(current_kbs, str): + continue + + # Convert list to string (take first one or empty) + if current_kbs and len(current_kbs) > 0: + config['ai']['local-agent']['knowledge-base'] = current_kbs[0] + else: + config['ai']['local-agent']['knowledge-base'] = '' + + # Remove new field + if 'knowledge-bases' in config['ai']['local-agent']: + del config['ai']['local-agent']['knowledge-bases'] + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) diff --git a/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py b/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py new file mode 100644 index 0000000..98070d4 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py @@ -0,0 +1,55 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(11) +class DBMigrateDifyApiConfig(migration.DBMigration): + """Dify base prompt config""" + + async def upgrade(self): + """Upgrade""" + # Read all pipelines using raw SQL + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + # Ensure nested structure exists + if 'ai' not in config: + config['ai'] = {} + if 'dify-service-api' not in config['ai']: + config['ai']['dify-service-api'] = {} + + # Add 'base-prompt' if not exists + if 'base-prompt' not in config['ai']['dify-service-api']: + config['ai']['dify-service-api']['base-prompt'] = ( + 'When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.', + ) + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py b/src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py new file mode 100644 index 0000000..6b1aca2 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py @@ -0,0 +1,73 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(12) +class DBMigratePipelineExtensionsEnableAll(migration.DBMigration): + """Pipeline extensions enable all""" + + async def upgrade(self): + """Upgrade""" + # Read all pipelines using raw SQL + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, extensions_preferences FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + extensions_preferences = ( + json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + ) + + # Ensure extensions_preferences is a dict + if extensions_preferences is None: + extensions_preferences = {} + + # Add 'enable_all_plugins' if not exists + if 'enable_all_plugins' not in extensions_preferences: + if 'plugins' in extensions_preferences: + extensions_preferences['enable_all_plugins'] = False + else: + extensions_preferences['enable_all_plugins'] = True + extensions_preferences['plugins'] = [] + + # Add 'enable_all_mcp_servers' if not exists + if 'enable_all_mcp_servers' not in extensions_preferences: + if 'mcp_servers' in extensions_preferences: + extensions_preferences['enable_all_mcp_servers'] = False + else: + extensions_preferences['enable_all_mcp_servers'] = True + extensions_preferences['mcp_servers'] = [] + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + { + 'extensions_preferences': json.dumps(extensions_preferences), + 'for_version': current_version, + 'uuid': uuid, + }, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences, for_version = :for_version WHERE uuid = :uuid' + ), + { + 'extensions_preferences': json.dumps(extensions_preferences), + 'for_version': current_version, + 'uuid': uuid, + }, + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py b/src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py new file mode 100644 index 0000000..19f37e8 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py @@ -0,0 +1,49 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(13) +class DBMigrateKnowledgeBaseUpdatedAt(migration.DBMigration): + """Add updated_at field to knowledge_bases table""" + + async def upgrade(self): + """Upgrade""" + # Get all column names from the table + columns = [] + + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + "SELECT column_name FROM information_schema.columns WHERE table_name = 'knowledge_bases';" + ) + ) + all_result = result.fetchall() + columns = [row[0] for row in all_result] + else: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(knowledge_bases);')) + all_result = result.fetchall() + columns = [row[1] for row in all_result] + + # Check and add updated_at column + if 'updated_at' not in columns: + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'ALTER TABLE knowledge_bases ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP' + ) + ) + else: + # SQLite doesn't support DEFAULT CURRENT_TIMESTAMP in ALTER TABLE + # Add column without default first + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE knowledge_bases ADD COLUMN updated_at DATETIME') + ) + + # Set initial updated_at values to created_at for existing records + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('UPDATE knowledge_bases SET updated_at = created_at WHERE updated_at IS NULL') + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py b/src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py new file mode 100644 index 0000000..ea93e93 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py @@ -0,0 +1,94 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(14) +class DBMigrateSpaceAccountSupport(migration.DBMigration): + """Add Space account support fields to users table""" + + async def upgrade(self): + """Upgrade""" + # Get all column names from the users table + columns = [] + + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("SELECT column_name FROM information_schema.columns WHERE table_name = 'users';") + ) + all_result = result.fetchall() + columns = [row[0] for row in all_result] + else: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(users);')) + all_result = result.fetchall() + columns = [row[1] for row in all_result] + + # Add account_type column + if 'account_type' not in columns: + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL") + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL") + ) + + # Add space_account_uuid column + if 'space_account_uuid' not in columns: + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)') + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)') + ) + + # Add space_access_token column + if 'space_access_token' not in columns: + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT') + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT') + ) + + # Add space_refresh_token column + if 'space_refresh_token' not in columns: + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT') + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT') + ) + + # Add space_access_token_expires_at column + if 'space_access_token_expires_at' not in columns: + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at TIMESTAMP') + ) + + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at DATETIME') + ) + + # Add space_api_key column + if 'space_api_key' not in columns: + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)') + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)') + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py b/src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py new file mode 100644 index 0000000..363ff66 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py @@ -0,0 +1,15 @@ +from .. import migration + + +# this is a deprecated migration +@migration.migration_class(15) +class DBMigrateModelSourceTracking(migration.DBMigration): + """Add source tracking fields to models tables for Space integration""" + + async def upgrade(self): + """Upgrade""" + pass + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py b/src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py new file mode 100644 index 0000000..150e520 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py @@ -0,0 +1,305 @@ +import uuid as uuid_lib + +import sqlalchemy +from .. import migration + + +@migration.migration_class(16) +class DBMigrateModelProviderRefactor(migration.DBMigration): + """Refactor model structure: create providers from existing models and update references""" + + async def upgrade(self): + """Upgrade""" + # Step 1: Create model_providers table if not exists + await self._create_providers_table() + + # Step 2: Migrate existing models to use providers + await self._migrate_llm_models() + await self._migrate_embedding_models() + + # Step 3: Remove deprecated columns + await self._cleanup_columns() + + async def _create_providers_table(self): + """Create model_providers table""" + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" + CREATE TABLE IF NOT EXISTS model_providers ( + uuid VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + requester VARCHAR(255) NOT NULL, + base_url VARCHAR(512) NOT NULL, + api_keys JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """) + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" + CREATE TABLE IF NOT EXISTS model_providers ( + uuid VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + requester VARCHAR(255) NOT NULL, + base_url VARCHAR(512) NOT NULL, + api_keys JSON NOT NULL DEFAULT '[]', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """) + ) + + async def _migrate_llm_models(self): + """Migrate LLM models to use providers""" + llm_columns = await self._get_columns('llm_models') + + # Add provider_uuid column if not exists + if 'provider_uuid' not in llm_columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN provider_uuid VARCHAR(255)') + ) + + # Add prefered_ranking column if not exists + if 'prefered_ranking' not in llm_columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0') + ) + + # Only migrate if old columns exist + if 'requester' not in llm_columns: + return + + # Get all LLM models with old structure + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM llm_models') + ) + models = result.fetchall() + + # Create providers and update models + provider_cache = {} # (requester, base_url, api_keys_str) -> provider_uuid + + for model in models: + model_uuid, model_name, requester, requester_config, api_keys = model + + # Extract base_url from requester_config + base_url = '' + if requester_config: + if isinstance(requester_config, str): + import json + + requester_config = json.loads(requester_config) + base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '') + + # Parse api_keys if it's a string + if isinstance(api_keys, str): + import json + + try: + api_keys = json.loads(api_keys) + except Exception: + api_keys = [] + if not api_keys: + api_keys = [] + + # Create cache key + api_keys_str = str(sorted(api_keys)) if api_keys else '[]' + cache_key = (requester, base_url, api_keys_str) + + if cache_key in provider_cache: + provider_uuid = provider_cache[cache_key] + else: + # Create new provider + provider_uuid = str(uuid_lib.uuid4()) + provider_name = f'{requester}' + if base_url: + # Extract domain for name + try: + from urllib.parse import urlparse + + parsed = urlparse(base_url) + provider_name = parsed.netloc or requester + except Exception: + pass + + import json + + api_keys_json = json.dumps(api_keys) if api_keys else '[]' + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" + INSERT INTO model_providers (uuid, name, requester, base_url, api_keys) + VALUES (:uuid, :name, :requester, :base_url, :api_keys) + """), + { + 'uuid': provider_uuid, + 'name': provider_name, + 'requester': requester, + 'base_url': base_url, + 'api_keys': api_keys_json, + }, + ) + provider_cache[cache_key] = provider_uuid + + # Update model with provider_uuid + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('UPDATE llm_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'), + {'provider_uuid': provider_uuid, 'uuid': model_uuid}, + ) + + async def _migrate_embedding_models(self): + """Migrate embedding models to use providers""" + embedding_columns = await self._get_columns('embedding_models') + + # Add provider_uuid column if not exists + if 'provider_uuid' not in embedding_columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN provider_uuid VARCHAR(255)') + ) + + # Add prefered_ranking column if not exists + if 'prefered_ranking' not in embedding_columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0') + ) + + # Only migrate if old columns exist + if 'requester' not in embedding_columns: + return + + # Get all embedding models with old structure + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM embedding_models') + ) + models = result.fetchall() + + # Get existing providers + provider_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, requester, base_url, api_keys FROM model_providers') + ) + existing_providers = provider_result.fetchall() + + provider_cache = {} + for p in existing_providers: + p_uuid, p_requester, p_base_url, p_api_keys = p + api_keys_str = str(sorted(p_api_keys)) if p_api_keys else '[]' + provider_cache[(p_requester, p_base_url, api_keys_str)] = p_uuid + + for model in models: + model_uuid, model_name, requester, requester_config, api_keys = model + + base_url = '' + if requester_config: + if isinstance(requester_config, str): + import json + + requester_config = json.loads(requester_config) + base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '') + + # Parse api_keys if it's a string + if isinstance(api_keys, str): + import json + + try: + api_keys = json.loads(api_keys) + except Exception: + api_keys = [] + if not api_keys: + api_keys = [] + + api_keys_str = str(sorted(api_keys)) if api_keys else '[]' + cache_key = (requester, base_url, api_keys_str) + + if cache_key in provider_cache: + provider_uuid = provider_cache[cache_key] + else: + provider_uuid = str(uuid_lib.uuid4()) + provider_name = f'{requester}' + if base_url: + try: + from urllib.parse import urlparse + + parsed = urlparse(base_url) + provider_name = parsed.netloc or requester + except Exception: + pass + + import json + + api_keys_json = json.dumps(api_keys) if api_keys else '[]' + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(""" + INSERT INTO model_providers (uuid, name, requester, base_url, api_keys) + VALUES (:uuid, :name, :requester, :base_url, :api_keys) + """), + { + 'uuid': provider_uuid, + 'name': provider_name, + 'requester': requester, + 'base_url': base_url, + 'api_keys': api_keys_json, + }, + ) + provider_cache[cache_key] = provider_uuid + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('UPDATE embedding_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'), + {'provider_uuid': provider_uuid, 'uuid': model_uuid}, + ) + + async def _cleanup_columns(self): + """Remove deprecated columns from model tables""" + + llm_columns = await self._get_columns('llm_models') + deprecated_llm_cols = ['requester', 'requester_config', 'api_keys', 'description', 'source', 'space_model_id'] + for col in deprecated_llm_cols: + if col in llm_columns: + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN IF EXISTS {col}') + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN {col}') + ) + + embedding_columns = await self._get_columns('embedding_models') + deprecated_embedding_cols = [ + 'requester', + 'requester_config', + 'api_keys', + 'description', + 'source', + 'space_model_id', + ] + for col in deprecated_embedding_cols: + if col in embedding_columns: + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN IF EXISTS {col}') + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN {col}') + ) + + async def _get_columns(self, table_name: str) -> list: + """Get column names for a table""" + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';" + ) + ) + all_result = result.fetchall() + return [row[0] for row in all_result] + else: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) + all_result = result.fetchall() + return [row[1] for row in all_result] + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py b/src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py new file mode 100644 index 0000000..ba68415 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py @@ -0,0 +1,25 @@ +from .. import migration + + +@migration.migration_class(17) +class MoveCloudServiceUrl(migration.DBMigration): + """迁移云服务 URL 配置""" + + async def upgrade(self): + """升级""" + if 'space' not in self.ap.instance_config.data: + self.ap.instance_config.data['space'] = { + 'url': 'https://space.langbot.app', + 'models_gateway_api_url': 'https://api.langbot.cloud/v1', + 'oauth_authorize_url': 'https://space.langbot.app/auth/authorize', + 'disable_models_service': False, + } + + if 'plugin' in self.ap.instance_config.data: + self.ap.instance_config.data['plugin'].pop('cloud_service_url', None) + + await self.ap.instance_config.dump_config() + + async def downgrade(self): + """降级""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py b/src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py new file mode 100644 index 0000000..f543a30 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py @@ -0,0 +1,58 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(18) +class DBMigrateAddEmojiSupport(migration.DBMigration): + """Add emoji field to knowledge_bases, external_knowledge_bases and legacy_pipelines tables""" + + async def upgrade(self): + """Upgrade""" + # Add emoji field to knowledge_bases + await self._add_emoji_to_table('knowledge_bases', '📚') + + # Add emoji field to external_knowledge_bases + await self._add_emoji_to_table('external_knowledge_bases', '🔗') + + # Add emoji field to legacy_pipelines + await self._add_emoji_to_table('legacy_pipelines', '⚙️') + + async def _add_emoji_to_table(self, table_name: str, default_emoji: str): + """Add emoji column to specified table if it doesn't exist""" + # Get all column names from the table + columns = [] + + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';" + ) + ) + all_result = result.fetchall() + columns = [row[0] for row in all_result] + else: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) + all_result = result.fetchall() + columns = [row[1] for row in all_result] + + # Check and add emoji column + if 'emoji' not in columns: + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(f"ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10) DEFAULT '{default_emoji}'") + ) + else: + # SQLite doesn't support DEFAULT with emoji directly in ALTER TABLE + # Add column without default first + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10)') + ) + + # Set default emoji value for existing records + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(f"UPDATE {table_name} SET emoji = '{default_emoji}' WHERE emoji IS NULL") + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py b/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py new file mode 100644 index 0000000..b1372aa --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py @@ -0,0 +1,24 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(19) +class DBMigrateMonitoringMessageRole(migration.DBMigration): + """Add role column to monitoring_messages table""" + + async def upgrade(self): + """Upgrade""" + try: + sql_text = sqlalchemy.text("ALTER TABLE monitoring_messages ADD COLUMN role VARCHAR(50) DEFAULT 'user'") + await self.ap.persistence_mgr.execute_async(sql_text) + except Exception: + # Column may already exist + pass + + async def downgrade(self): + """Downgrade""" + try: + sql_text = sqlalchemy.text('ALTER TABLE monitoring_messages DROP COLUMN role') + await self.ap.persistence_mgr.execute_async(sql_text) + except Exception: + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py b/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py new file mode 100644 index 0000000..616cb91 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py @@ -0,0 +1,161 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(20) +class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration): + """Migrate to unified Knowledge Engine plugin architecture. + + Changes: + - Backup existing knowledge_bases data to knowledge_bases_backup + - Clear knowledge_bases table and add new plugin architecture columns + - Drop old columns (PostgreSQL only; SQLite leaves them unmapped) + - Preserve external_knowledge_bases table as-is for future migration + - Set rag_plugin_migration_needed flag in metadata if old data exists + """ + + async def upgrade(self): + """Upgrade""" + has_internal_data = await self._backup_knowledge_bases() + has_external_data = await self._check_external_knowledge_bases() + await self._clear_knowledge_bases() + await self._add_columns_to_knowledge_bases() + await self._drop_old_columns() + if has_internal_data or has_external_data: + await self._set_migration_flag() + + async def _get_table_columns(self, table_name: str) -> list[str]: + """Get column names from a table (works for both SQLite and PostgreSQL).""" + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;' + ).bindparams(table_name=table_name) + ) + return [row[0] for row in result.fetchall()] + else: + # SQLite PRAGMA does not support bind parameters; validate identifier. + if not table_name.isidentifier(): + raise ValueError(f'Invalid table name: {table_name}') + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) + return [row[1] for row in result.fetchall()] + + async def _table_exists(self, table_name: str) -> bool: + """Check if a table exists.""" + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' + ).bindparams(table_name=table_name) + ) + return result.scalar() + else: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( + table_name=table_name + ) + ) + return result.first() is not None + + async def _backup_knowledge_bases(self) -> bool: + """Backup knowledge_bases data. Returns True if data was backed up.""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases;')) + count = result.scalar() + if count == 0: + return False + + # Drop backup table if it already exists (from a previous failed migration) + if await self._table_exists('knowledge_bases_backup'): + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE knowledge_bases_backup;')) + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('CREATE TABLE knowledge_bases_backup AS SELECT * FROM knowledge_bases;') + ) + self.ap.logger.info( + 'Backed up %d knowledge base(s) to knowledge_bases_backup table.', + count, + ) + return True + + async def _check_external_knowledge_bases(self) -> bool: + """Check if external_knowledge_bases table exists and has data. + + The table is preserved as-is (not dropped) for future migration. + """ + if not await self._table_exists('external_knowledge_bases'): + return False + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;') + ) + count = result.scalar() + if count > 0: + self.ap.logger.info( + 'Found %d external knowledge base(s) in external_knowledge_bases table. ' + 'Table preserved for future migration.', + count, + ) + return count > 0 + + async def _clear_knowledge_bases(self): + """Clear all rows from knowledge_bases table (preserve table structure).""" + await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DELETE FROM knowledge_bases;')) + + async def _add_columns_to_knowledge_bases(self): + """Add new RAG plugin architecture columns to knowledge_bases table.""" + columns = await self._get_table_columns('knowledge_bases') + + new_columns = { + 'knowledge_engine_plugin_id': 'VARCHAR', + 'collection_id': 'VARCHAR', + 'creation_settings': 'TEXT', # JSON stored as TEXT for SQLite compatibility + 'retrieval_settings': 'TEXT', + } + + for col_name, col_type in new_columns.items(): + if col_name not in columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};') + ) + + async def _drop_old_columns(self): + """Drop embedding_model_uuid and top_k columns (PostgreSQL only). + + SQLite does not support DROP COLUMN in older versions, so we leave the + columns in place — the SQLAlchemy entity simply won't map them. + """ + if self.ap.persistence_mgr.db.name != 'postgresql': + return + + columns = await self._get_table_columns('knowledge_bases') + + if 'embedding_model_uuid' in columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN embedding_model_uuid;') + ) + + if 'top_k' in columns: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;') + ) + + async def _set_migration_flag(self): + """Set rag_plugin_migration_needed flag in metadata table.""" + # Check if the key already exists + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';") + ) + row = result.first() + if row is not None: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("UPDATE metadata SET value = 'true' WHERE key = 'rag_plugin_migration_needed';") + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');") + ) + self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.') + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py b/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py new file mode 100644 index 0000000..59c1e35 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py @@ -0,0 +1,74 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(21) +class DBMigrateMergeExceptionHandling(migration.DBMigration): + """Merge hide-exception and block-failed-request-output into a single exception-handling select option, + and add failure-hint field. + + Conversion logic: + - block-failed-request-output=true -> exception-handling: hide + - hide-exception=true -> exception-handling: show-hint + - hide-exception=false -> exception-handling: show-error + """ + + async def upgrade(self): + """Upgrade""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + if 'output' not in config: + config['output'] = {} + if 'misc' not in config['output']: + config['output']['misc'] = {} + + misc = config['output']['misc'] + + # Determine new exception-handling value from legacy fields + hide_exception = misc.get('hide-exception', True) + block_failed = misc.get('block-failed-request-output', False) + + if block_failed: + exception_handling = 'hide' + elif hide_exception: + exception_handling = 'show-hint' + else: + exception_handling = 'show-error' + + misc['exception-handling'] = exception_handling + + # Add failure-hint with default value + misc['failure-hint'] = 'Request failed.' + + # Remove legacy fields + misc.pop('hide-exception', None) + + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py b/src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py new file mode 100644 index 0000000..b66adc7 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py @@ -0,0 +1,73 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(22) +class DBMigrateMonitoringUserId(migration.DBMigration): + """Add user_id and user_name columns to monitoring_sessions table + + This migration adds the missing user_id column and also ensures user_name + column exists (in case migration 21 failed or was skipped). + """ + + async def _table_exists(self, table_name: str) -> bool: + """Check if a table exists (works for both SQLite and PostgreSQL).""" + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' + ).bindparams(table_name=table_name) + ) + return bool(result.scalar()) + else: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( + table_name=table_name + ) + ) + return result.first() is not None + + async def _get_table_columns(self, table_name: str) -> list[str]: + """Get column names from a table (works for both SQLite and PostgreSQL).""" + if self.ap.persistence_mgr.db.name == 'postgresql': + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;' + ).bindparams(table_name=table_name) + ) + return [row[0] for row in result.fetchall()] + else: + if not table_name.isidentifier(): + raise ValueError(f'Invalid table name: {table_name}') + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) + return [row[1] for row in result.fetchall()] + + async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str): + """Add a column to a table if it does not already exist.""" + columns = await self._get_table_columns(table_name) + if column_name in columns: + self.ap.logger.debug('%s column already exists in %s.', column_name, table_name) + return + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};') + ) + self.ap.logger.info('Added %s column to %s table.', column_name, table_name) + + async def upgrade(self): + # Check if monitoring_sessions table exists + if not await self._table_exists('monitoring_sessions'): + self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.') + return + + # Add user_id column to monitoring_sessions table + await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)') + + # Add user_name column to monitoring_sessions table (in case migration 21 failed) + await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)') + + # Add user_name column to monitoring_messages table (in case migration 21 failed) + if await self._table_exists('monitoring_messages'): + await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)') + + async def downgrade(self): + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py b/src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py new file mode 100644 index 0000000..11ab3bb --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py @@ -0,0 +1,102 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(23) +class DBMigrateModelFallbackConfig(migration.DBMigration): + """Convert model field from plain UUID string to object with primary/fallbacks""" + + async def upgrade(self): + """Upgrade""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + if 'ai' not in config or 'local-agent' not in config['ai']: + continue + + local_agent = config['ai']['local-agent'] + changed = False + + # Convert model from string to object + model_value = local_agent.get('model', '') + if isinstance(model_value, str): + local_agent['model'] = { + 'primary': model_value, + 'fallbacks': [], + } + changed = True + + # Remove leftover fallback-models field if present + if 'fallback-models' in local_agent: + del local_agent['fallback-models'] + changed = True + + if not changed: + continue + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + + async def downgrade(self): + """Downgrade""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') + ) + pipelines = result.fetchall() + + current_version = self.ap.ver_mgr.get_current_version() + + for pipeline_row in pipelines: + uuid = pipeline_row[0] + config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] + + if 'ai' not in config or 'local-agent' not in config['ai']: + continue + + local_agent = config['ai']['local-agent'] + + # Convert model from object back to string + model_value = local_agent.get('model', '') + if isinstance(model_value, dict): + local_agent['model'] = model_value.get('primary', '') + else: + continue + + # Update using raw SQL with compatibility for both SQLite and PostgreSQL + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text( + 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' + ), + {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, + ) diff --git a/src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py b/src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py new file mode 100644 index 0000000..a5b833b --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py @@ -0,0 +1,49 @@ +from .. import migration + +import sqlalchemy +import json + + +@migration.migration_class(24) +class DBMigrateWecomBotWebSocketMode(migration.DBMigration): + """Add enable-webhook field to existing wecombot adapter configs. + + Existing wecombot bots were all using webhook mode, so we set + enable-webhook=true to preserve their behavior after the new + WebSocket long connection mode is introduced as default. + """ + + async def upgrade(self): + """Upgrade""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.text("SELECT uuid, adapter_config FROM bots WHERE adapter = 'wecombot'") + ) + bots = result.fetchall() + + for bot_row in bots: + bot_uuid = bot_row[0] + adapter_config = json.loads(bot_row[1]) if isinstance(bot_row[1], str) else bot_row[1] + + if 'enable-webhook' in adapter_config: + continue + + # Determine mode based on existing config: if webhook fields are present, keep webhook mode + has_webhook_config = bool( + adapter_config.get('Token') and adapter_config.get('EncodingAESKey') and adapter_config.get('Corpid') + ) + adapter_config['enable-webhook'] = has_webhook_config + + if self.ap.persistence_mgr.db.name == 'postgresql': + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('UPDATE bots SET adapter_config = :config::jsonb WHERE uuid = :uuid'), + {'config': json.dumps(adapter_config), 'uuid': bot_uuid}, + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.text('UPDATE bots SET adapter_config = :config WHERE uuid = :uuid'), + {'config': json.dumps(adapter_config), 'uuid': bot_uuid}, + ) + + async def downgrade(self): + """Downgrade""" + pass diff --git a/src/langbot/pkg/persistence/migrations/dbm025_bot_pipeline_routing_rules.py b/src/langbot/pkg/persistence/migrations/dbm025_bot_pipeline_routing_rules.py new file mode 100644 index 0000000..816bf28 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm025_bot_pipeline_routing_rules.py @@ -0,0 +1,15 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(25) +class DBMigrateBotPipelineRoutingRules(migration.DBMigration): + """Add pipeline_routing_rules column to bots table""" + + async def upgrade(self): + sql_text = sqlalchemy.text("ALTER TABLE bots ADD COLUMN pipeline_routing_rules JSON NOT NULL DEFAULT '[]'") + await self.ap.persistence_mgr.execute_async(sql_text) + + async def downgrade(self): + sql_text = sqlalchemy.text('ALTER TABLE bots DROP COLUMN pipeline_routing_rules') + await self.ap.persistence_mgr.execute_async(sql_text) diff --git a/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py b/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py new file mode 100644 index 0000000..8bd71b1 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py @@ -0,0 +1,17 @@ +from langbot.pkg.entity.persistence import monitoring as persistence_monitoring +from .. import migration + + +@migration.migration_class(26) +class DBMigrateMonitoringToolCalls(migration.DBMigration): + """Add monitoring_tool_calls table""" + + async def upgrade(self): + """Upgrade""" + async with self.ap.persistence_mgr.get_db_engine().begin() as conn: + await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.create, checkfirst=True) + + async def downgrade(self): + """Downgrade""" + async with self.ap.persistence_mgr.get_db_engine().begin() as conn: + await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.drop, checkfirst=True) diff --git a/src/langbot/pkg/pipeline/__init__.py b/src/langbot/pkg/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/aggregator.py b/src/langbot/pkg/pipeline/aggregator.py new file mode 100644 index 0000000..96358e3 --- /dev/null +++ b/src/langbot/pkg/pipeline/aggregator.py @@ -0,0 +1,296 @@ +"""Message Aggregator Module + +This module provides message aggregation/debounce functionality. +When users send multiple messages consecutively, the aggregator will wait +for a configurable delay period and merge them into a single message +before processing. +""" + +from __future__ import annotations + +import asyncio +import time +import typing +from dataclasses import dataclass, field + +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.provider.session as provider_session +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter + +if typing.TYPE_CHECKING: + from ..core import app + +# Maximum number of messages to buffer before forcing a flush +MAX_BUFFER_MESSAGES = 10 + + +@dataclass +class PendingMessage: + """A pending message waiting to be aggregated""" + + bot_uuid: str + launcher_type: provider_session.LauncherTypes + launcher_id: typing.Union[int, str] + sender_id: typing.Union[int, str] + message_event: platform_events.MessageEvent + message_chain: platform_message.MessageChain + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter + pipeline_uuid: typing.Optional[str] + routed_by_rule: bool = False + timestamp: float = field(default_factory=time.time) + + +@dataclass +class SessionBuffer: + """Buffer for a single session's pending messages""" + + session_id: str + messages: list[PendingMessage] = field(default_factory=list) + timer_task: typing.Optional[asyncio.Task] = None + last_message_time: float = field(default_factory=time.time) + + +class MessageAggregator: + """Message aggregator that buffers and merges consecutive messages + + This class implements a debounce mechanism for incoming messages. + When a message arrives, it starts a timer. If more messages arrive + before the timer expires, they are buffered. When the timer expires, + all buffered messages are merged and sent to the query pool. + """ + + ap: app.Application + + buffers: dict[str, SessionBuffer] + """Session ID -> SessionBuffer mapping""" + + lock: asyncio.Lock + """Lock for thread-safe buffer operations""" + + def __init__(self, ap: app.Application): + self.ap = ap + self.buffers = {} + self.lock = asyncio.Lock() + + def _get_session_id( + self, + bot_uuid: str, + launcher_type: provider_session.LauncherTypes, + launcher_id: typing.Union[int, str], + ) -> str: + """Generate a unique session ID""" + return f'{bot_uuid}:{launcher_type.value}:{launcher_id}' + + async def _get_aggregation_config(self, pipeline_uuid: typing.Optional[str]) -> tuple[bool, float]: + """Get aggregation configuration for a pipeline + + Returns: + tuple: (enabled, delay_seconds) + """ + default_enabled = False + default_delay = 1.5 + + if pipeline_uuid is None: + return default_enabled, default_delay + + # Get pipeline from pipeline manager + pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid) + if pipeline is None: + return default_enabled, default_delay + + config = pipeline.pipeline_entity.config or {} + trigger_config = config.get('trigger', {}) + aggregation_config = trigger_config.get('message-aggregation', {}) + + enabled = aggregation_config.get('enabled', default_enabled) + + delay_raw = aggregation_config.get('delay', default_delay) + try: + delay = float(delay_raw) + except (TypeError, ValueError): + delay = default_delay + + # Clamp delay to valid range + delay = max(1.0, min(10.0, delay)) + + return enabled, delay + + async def add_message( + self, + bot_uuid: str, + launcher_type: provider_session.LauncherTypes, + launcher_id: typing.Union[int, str], + sender_id: typing.Union[int, str], + message_event: platform_events.MessageEvent, + message_chain: platform_message.MessageChain, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + pipeline_uuid: typing.Optional[str] = None, + routed_by_rule: bool = False, + ) -> None: + """Add a message to the aggregation buffer + + If aggregation is disabled for the pipeline, the message is sent + directly to the query pool. Otherwise, it's buffered and will be + merged with other messages from the same session. + """ + enabled, delay = await self._get_aggregation_config(pipeline_uuid) + + if not enabled: + # Aggregation disabled, send directly to query pool + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_id, + message_event=message_event, + message_chain=message_chain, + adapter=adapter, + pipeline_uuid=pipeline_uuid, + routed_by_rule=routed_by_rule, + ) + return + + session_id = self._get_session_id(bot_uuid, launcher_type, launcher_id) + + pending_msg = PendingMessage( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_id, + message_event=message_event, + message_chain=message_chain, + adapter=adapter, + pipeline_uuid=pipeline_uuid, + routed_by_rule=routed_by_rule, + ) + + force_flush = False + async with self.lock: + if session_id in self.buffers: + buffer = self.buffers[session_id] + # Cancel existing timer (just cancel, don't await inside lock) + if buffer.timer_task and not buffer.timer_task.done(): + buffer.timer_task.cancel() + buffer.messages.append(pending_msg) + else: + buffer = SessionBuffer( + session_id=session_id, + messages=[pending_msg], + ) + self.buffers[session_id] = buffer + + buffer.last_message_time = time.time() + + # Check if buffer reached max capacity + if len(buffer.messages) >= MAX_BUFFER_MESSAGES: + force_flush = True + else: + # Start new timer + buffer.timer_task = asyncio.create_task(self._delayed_flush(session_id, delay)) + + if force_flush: + await self._flush_buffer(session_id) + + async def _delayed_flush(self, session_id: str, delay: float) -> None: + """Wait for delay then flush the buffer""" + try: + await asyncio.sleep(delay) + await self._flush_buffer(session_id) + except asyncio.CancelledError: + # Timer was cancelled, new message arrived + pass + + async def _flush_buffer(self, session_id: str) -> None: + """Flush the buffer for a session, merging all messages""" + async with self.lock: + buffer = self.buffers.pop(session_id, None) + + if buffer is None or not buffer.messages: + return + + if len(buffer.messages) == 1: + # Only one message, no need to merge + msg = buffer.messages[0] + await self.ap.query_pool.add_query( + bot_uuid=msg.bot_uuid, + launcher_type=msg.launcher_type, + launcher_id=msg.launcher_id, + sender_id=msg.sender_id, + message_event=msg.message_event, + message_chain=msg.message_chain, + adapter=msg.adapter, + pipeline_uuid=msg.pipeline_uuid, + routed_by_rule=msg.routed_by_rule, + ) + return + + # Merge multiple messages + merged_msg = self._merge_messages(buffer.messages) + await self.ap.query_pool.add_query( + bot_uuid=merged_msg.bot_uuid, + launcher_type=merged_msg.launcher_type, + launcher_id=merged_msg.launcher_id, + sender_id=merged_msg.sender_id, + message_event=merged_msg.message_event, + message_chain=merged_msg.message_chain, + adapter=merged_msg.adapter, + pipeline_uuid=merged_msg.pipeline_uuid, + routed_by_rule=merged_msg.routed_by_rule, + ) + + def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage: + """Merge multiple messages into one + + The merged message uses the first message as base and combines + all message chains with newline separators. + The original message_event is kept unmodified to preserve + message metadata (message_id, etc.) for reply/quote. + """ + if len(messages) == 1: + return messages[0] + + base_msg = messages[0] + + # Build merged message chain + merged_chain = platform_message.MessageChain([]) + + for i, msg in enumerate(messages): + if i > 0: + # Add newline separator between messages + merged_chain.append(platform_message.Plain(text='\n')) + + # Copy all components from this message + for component in msg.message_chain: + merged_chain.append(component) + + # Keep message_event unmodified (preserves original message_id and + # metadata for reply/quote), only pass merged chain separately + return PendingMessage( + bot_uuid=base_msg.bot_uuid, + launcher_type=base_msg.launcher_type, + launcher_id=base_msg.launcher_id, + sender_id=base_msg.sender_id, + message_event=base_msg.message_event, + message_chain=merged_chain, + adapter=base_msg.adapter, + pipeline_uuid=base_msg.pipeline_uuid, + routed_by_rule=any(msg.routed_by_rule for msg in messages), + ) + + async def flush_all(self) -> None: + """Flush all pending buffers immediately + + This is useful during shutdown to ensure no messages are lost. + """ + # Snapshot session IDs and cancel all timers under lock + async with self.lock: + session_ids = list(self.buffers.keys()) + for sid in session_ids: + buffer = self.buffers.get(sid) + if buffer and buffer.timer_task and not buffer.timer_task.done(): + buffer.timer_task.cancel() + + # Flush each buffer outside the lock + for session_id in session_ids: + await self._flush_buffer(session_id) diff --git a/src/langbot/pkg/pipeline/bansess/__init__.py b/src/langbot/pkg/pipeline/bansess/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/bansess/bansess.py b/src/langbot/pkg/pipeline/bansess/bansess.py new file mode 100644 index 0000000..a1cad2b --- /dev/null +++ b/src/langbot/pkg/pipeline/bansess/bansess.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from .. import stage, entities +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@stage.stage_class('BanSessionCheckStage') +class BanSessionCheckStage(stage.PipelineStage): + """Access control processing stage + + Only check if the group or personal number in the query is in the access control list. + """ + + async def initialize(self, pipeline_config: dict): + pass + + async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: + found = False + + mode = query.pipeline_config['trigger']['access-control']['mode'] + + sess_list = query.pipeline_config['trigger']['access-control'][mode] + + if (query.launcher_type.value == 'group' and 'group_*' in sess_list) or ( + query.launcher_type.value == 'person' and 'person_*' in sess_list + ): + found = True + else: + for sess in sess_list: + if sess == f'{query.launcher_type.value}_{query.launcher_id}': + found = True + break + # 使用 *_id 来表示加白/拉黑某用户的私聊和群聊场景 + if sess.startswith('*_') and (sess[2:] == query.launcher_id or sess[2:] == query.sender_id): + found = True + break + + ctn = False + + if mode == 'whitelist': + ctn = found + else: + ctn = not found + + return entities.StageProcessResult( + result_type=entities.ResultType.CONTINUE if ctn else entities.ResultType.INTERRUPT, + new_query=query, + console_notice=f'Ignore message according to access control: {query.launcher_type.value}_{query.launcher_id}' + if not ctn + else '', + ) diff --git a/src/langbot/pkg/pipeline/cntfilter/__init__.py b/src/langbot/pkg/pipeline/cntfilter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/cntfilter/cntfilter.py b/src/langbot/pkg/pipeline/cntfilter/cntfilter.py new file mode 100644 index 0000000..26b0041 --- /dev/null +++ b/src/langbot/pkg/pipeline/cntfilter/cntfilter.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from ...core import app + +from .. import stage, entities +from . import filter as filter_model, entities as filter_entities +from langbot_plugin.api.entities.builtin.provider import message as provider_message +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from ...utils import importutil +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +from . import filters + +importutil.import_modules_in_pkg(filters) + + +@stage.stage_class('PostContentFilterStage') +@stage.stage_class('PreContentFilterStage') +class ContentFilterStage(stage.PipelineStage): + """内容过滤阶段 + + 前置: + 检查消息是否符合规则,不符合则拦截。 + 改写: + message_chain + + 后置: + 检查AI回复消息是否符合规则,可能进行改写,不符合则拦截。 + 改写: + query.resp_messages + """ + + filter_chain: list[filter_model.ContentFilter] + + def __init__(self, ap: app.Application): + self.filter_chain = [] + super().__init__(ap) + + async def initialize(self, pipeline_config: dict): + filters_required = [ + 'content-ignore', + ] + + if pipeline_config['safety']['content-filter']['check-sensitive-words']: + filters_required.append('ban-word-filter') + + # TODO revert it + # if self.ap.pipeline_cfg.data['baidu-cloud-examine']['enable']: + # filters_required.append("baidu-cloud-examine") + + for filter in filter_model.preregistered_filters: + if filter.name in filters_required: + self.filter_chain.append(filter(self.ap)) + + for filter in self.filter_chain: + await filter.initialize() + + async def _pre_process( + self, + message: str, + query: pipeline_query.Query, + ) -> entities.StageProcessResult: + """请求llm前处理消息 + 只要有一个不通过就不放行,只放行 PASS 的消息 + """ + + if query.pipeline_config['safety']['content-filter']['scope'] == 'output-msg': + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + if not message.strip(): + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + else: + for filter in self.filter_chain: + if filter_entities.EnableStage.PRE in filter.enable_stages: + result = await filter.process(query, message) + + if result.level in [ + filter_entities.ResultLevel.BLOCK, + filter_entities.ResultLevel.MASKED, + ]: + return entities.StageProcessResult( + result_type=entities.ResultType.INTERRUPT, + new_query=query, + user_notice=result.user_notice, + console_notice=result.console_notice, + ) + elif result.level == filter_entities.ResultLevel.PASS: # 传到下一个 + message = result.replacement + + query.message_chain = platform_message.MessageChain([platform_message.Plain(text=message)]) + + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + + async def _post_process( + self, + message: str, + query: pipeline_query.Query, + ) -> entities.StageProcessResult: + """请求llm后处理响应 + 只要是 PASS 或者 MASKED 的就通过此 filter,将其 replacement 设置为message,进入下一个 filter + """ + if query.pipeline_config['safety']['content-filter']['scope'] == 'income-msg': + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + else: + message = message.strip() + for filter in self.filter_chain: + if filter_entities.EnableStage.POST in filter.enable_stages: + result = await filter.process(query, message) + + if result.level == filter_entities.ResultLevel.BLOCK: + return entities.StageProcessResult( + result_type=entities.ResultType.INTERRUPT, + new_query=query, + user_notice=result.user_notice, + console_notice=result.console_notice, + ) + elif result.level in [ + filter_entities.ResultLevel.PASS, + filter_entities.ResultLevel.MASKED, + ]: + message = result.replacement + + query.resp_messages[-1].content = message + + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + + async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: + """处理""" + if stage_inst_name == 'PreContentFilterStage': + contain_non_text = False + + text_components = [platform_message.Plain, platform_message.Source] + + for me in query.message_chain: + if type(me) not in text_components: + contain_non_text = True + break + + if contain_non_text: + self.ap.logger.debug('消息中包含非文本消息,跳过内容过滤器检查。') + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + + return await self._pre_process(str(query.message_chain).strip(), query) + elif stage_inst_name == 'PostContentFilterStage': + # 仅处理 query.resp_messages[-1].content 是 str 的情况 + if isinstance(query.resp_messages[-1], provider_message.Message) and isinstance( + query.resp_messages[-1].content, str + ): + return await self._post_process(query.resp_messages[-1].content, query) + else: + self.ap.logger.debug( + 'resp_messages[-1] 不是 Message 类型或 query.resp_messages[-1].content 不是 str 类型,跳过内容过滤器检查。' + ) + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + else: + raise ValueError(f'未知的 stage_inst_name: {stage_inst_name}') diff --git a/src/langbot/pkg/pipeline/cntfilter/entities.py b/src/langbot/pkg/pipeline/cntfilter/entities.py new file mode 100644 index 0000000..607eba9 --- /dev/null +++ b/src/langbot/pkg/pipeline/cntfilter/entities.py @@ -0,0 +1,74 @@ +import enum + +import pydantic + + +class ResultLevel(enum.Enum): + """结果等级""" + + PASS = enum.auto() + """通过""" + + WARN = enum.auto() + """警告""" + + MASKED = enum.auto() + """已掩去""" + + BLOCK = enum.auto() + """阻止""" + + +class EnableStage(enum.Enum): + """启用阶段""" + + PRE = enum.auto() + """预处理""" + + POST = enum.auto() + """后处理""" + + +class FilterResult(pydantic.BaseModel): + level: ResultLevel + """结果等级 + + 对于前置处理阶段,只要有任意一个返回 非PASS 的内容过滤器结果,就会中断处理。 + 对于后置处理阶段,当且内容过滤器返回 BLOCK 时,会中断处理。 + """ + + replacement: str + """替换后的文本消息 + + 内容过滤器可以进行一些遮掩处理,然后把遮掩后的消息返回。 + 若没有修改内容,也需要返回原消息。 + """ + + user_notice: str + """不通过时,若此值不为空,将对用户提示消息""" + + console_notice: str + """不通过时,若此值不为空,将在控制台提示消息""" + + +class ManagerResultLevel(enum.Enum): + """处理器结果等级""" + + CONTINUE = enum.auto() + """继续""" + + INTERRUPT = enum.auto() + """中断""" + + +class FilterManagerResult(pydantic.BaseModel): + level: ManagerResultLevel + + replacement: str + """替换后的消息""" + + user_notice: str + """用户提示消息""" + + console_notice: str + """控制台提示消息""" diff --git a/src/langbot/pkg/pipeline/cntfilter/filter.py b/src/langbot/pkg/pipeline/cntfilter/filter.py new file mode 100644 index 0000000..58804fd --- /dev/null +++ b/src/langbot/pkg/pipeline/cntfilter/filter.py @@ -0,0 +1,76 @@ +# 内容过滤器的抽象类 +from __future__ import annotations +import abc +import typing + +from ...core import app +from . import entities +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + +preregistered_filters: list[typing.Type[ContentFilter]] = [] + + +def filter_class( + name: str, +) -> typing.Callable[[typing.Type[ContentFilter]], typing.Type[ContentFilter]]: + """Content filter class decorator + + Args: + name (str): Filter name + + Returns: + typing.Callable[[typing.Type[ContentFilter]], typing.Type[ContentFilter]]: Decorator + """ + + def decorator(cls: typing.Type[ContentFilter]) -> typing.Type[ContentFilter]: + assert issubclass(cls, ContentFilter) + + cls.name = name + + preregistered_filters.append(cls) + + return cls + + return decorator + + +class ContentFilter(metaclass=abc.ABCMeta): + """Content filter abstract class""" + + name: str + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + @property + def enable_stages(self): + """Enabled stages + + Default is the two stages before and after the message request to AI. + + entity.EnableStage.PRE: Before message request to AI, the content to check is the user's input message. + entity.EnableStage.POST: After message request to AI, the content to check is the AI's reply message. + """ + return [entities.EnableStage.PRE, entities.EnableStage.POST] + + async def initialize(self): + """Initialize filter""" + pass + + @abc.abstractmethod + async def process(self, query: pipeline_query.Query, message: str = None, image_url=None) -> entities.FilterResult: + """处理消息 + + It is divided into two stages, depending on the value of enable_stages. + For content filters, you do not need to consider the stage of the message, you only need to check the message content. + + Args: + message (str): Content to check + image_url (str): URL of the image to check + + Returns: + entities.FilterResult: Filter result, please refer to the documentation of entities.FilterResult class + """ + raise NotImplementedError diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/__init__.py b/src/langbot/pkg/pipeline/cntfilter/filters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py b/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py new file mode 100644 index 0000000..a376310 --- /dev/null +++ b/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from .. import entities +from .. import filter as filter_model +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +from langbot.pkg.utils import httpclient + +BAIDU_EXAMINE_URL = 'https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined?access_token={}' +BAIDU_EXAMINE_TOKEN_URL = 'https://aip.baidubce.com/oauth/2.0/token' + + +@filter_model.filter_class('baidu-cloud-examine') +class BaiduCloudExamine(filter_model.ContentFilter): + """百度云内容审核""" + + async def _get_token(self) -> str: + session = httpclient.get_session() + async with session.post( + BAIDU_EXAMINE_TOKEN_URL, + params={ + 'grant_type': 'client_credentials', + 'client_id': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-key'], + 'client_secret': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-secret'], + }, + ) as resp: + return (await resp.json())['access_token'] + + async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult: + session = httpclient.get_session() + async with session.post( + BAIDU_EXAMINE_URL.format(await self._get_token()), + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + data=f'text={message}'.encode('utf-8'), + ) as resp: + result = await resp.json() + + if 'error_code' in result: + return entities.FilterResult( + level=entities.ResultLevel.BLOCK, + replacement=message, + user_notice='', + console_notice=f'百度云判定出错,错误信息:{result["error_msg"]}', + ) + else: + conclusion = result['conclusion'] + + if conclusion in ('合规'): + return entities.FilterResult( + level=entities.ResultLevel.PASS, + replacement=message, + user_notice='', + console_notice=f'百度云判定结果:{conclusion}', + ) + else: + return entities.FilterResult( + level=entities.ResultLevel.BLOCK, + replacement=message, + user_notice='消息中存在不合适的内容, 请修改', + console_notice=f'百度云判定结果:{conclusion}', + ) diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py new file mode 100644 index 0000000..05b2501 --- /dev/null +++ b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py @@ -0,0 +1,39 @@ +from __future__ import annotations +import re + +from .. import filter as filter_model +from .. import entities +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@filter_model.filter_class('ban-word-filter') +class BanWordFilter(filter_model.ContentFilter): + """Filter content""" + + async def initialize(self): + pass + + async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult: + found = False + + for word in self.ap.sensitive_meta.data['words']: + match = re.findall(word, message) + + if len(match) > 0: + found = True + + for i in range(len(match)): + if self.ap.sensitive_meta.data['mask_word'] == '': + message = message.replace( + match[i], + self.ap.sensitive_meta.data['mask'] * len(match[i]), + ) + else: + message = message.replace(match[i], self.ap.sensitive_meta.data['mask_word']) + + return entities.FilterResult( + level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS, + replacement=message, + user_notice='消息中存在不合适的内容, 请修改' if found else '', + console_notice='', + ) diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py b/src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py new file mode 100644 index 0000000..731ab39 --- /dev/null +++ b/src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py @@ -0,0 +1,45 @@ +from __future__ import annotations +import re + +from .. import entities +from .. import filter as filter_model +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@filter_model.filter_class('content-ignore') +class ContentIgnore(filter_model.ContentFilter): + """Ignore message according to content""" + + @property + def enable_stages(self): + return [ + entities.EnableStage.PRE, + ] + + async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult: + if 'prefix' in query.pipeline_config['trigger']['ignore-rules']: + for rule in query.pipeline_config['trigger']['ignore-rules']['prefix']: + if message.startswith(rule): + return entities.FilterResult( + level=entities.ResultLevel.BLOCK, + replacement='', + user_notice='', + console_notice='Ignore message according to prefix rule in ignore_rules', + ) + + if 'regexp' in query.pipeline_config['trigger']['ignore-rules']: + for rule in query.pipeline_config['trigger']['ignore-rules']['regexp']: + if re.search(rule, message): + return entities.FilterResult( + level=entities.ResultLevel.BLOCK, + replacement='', + user_notice='', + console_notice='Ignore message according to regexp rule in ignore_rules', + ) + + return entities.FilterResult( + level=entities.ResultLevel.PASS, + replacement=message, + user_notice='', + console_notice='', + ) diff --git a/src/langbot/pkg/pipeline/config_coercion.py b/src/langbot/pkg/pipeline/config_coercion.py new file mode 100644 index 0000000..649f905 --- /dev/null +++ b/src/langbot/pkg/pipeline/config_coercion.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +# metadata type -> coercion function +_COERCE_MAP = { + 'integer': lambda v: int(v), + 'number': lambda v: float(v), + 'float': lambda v: float(v), +} + + +def _coerce_bool(v): + if isinstance(v, bool): + return v + if isinstance(v, str): + if v.lower() == 'true': + return True + if v.lower() == 'false': + return False + raise ValueError(f'Cannot convert string {v!r} to bool') + return bool(v) + + +def _coerce_value(value, expected_type: str): + """Convert a single value to the expected type. + + Returns the converted value, or the original value if no conversion needed. + """ + if value is None: + return value + + if expected_type == 'boolean': + if isinstance(value, bool): + return value + return _coerce_bool(value) + + coerce_fn = _COERCE_MAP.get(expected_type) + if coerce_fn is None: + return value + + # Already the correct type + if expected_type == 'integer' and isinstance(value, int) and not isinstance(value, bool): + return value + if expected_type in ('number', 'float') and isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + + return coerce_fn(value) + + +def coerce_pipeline_config( + config: dict, + *metadata_list: dict, +) -> None: + """Coerce pipeline config values according to metadata type definitions. + + Walks each metadata dict (trigger, safety, ai, output) and converts + config values in-place so that strings coming from the JSON column are + cast to their declared types (integer, number/float, boolean). + + Args: + config: The pipeline config dict to modify in-place. + *metadata_list: Metadata dicts loaded from the YAML templates. + """ + for meta in metadata_list: + section_name = meta.get('name') + if not section_name or section_name not in config: + continue + + section = config[section_name] + if not isinstance(section, dict): + continue + + for stage_def in meta.get('stages', []): + stage_name = stage_def.get('name') + if not stage_name or stage_name not in section: + continue + + stage_config = section[stage_name] + if not isinstance(stage_config, dict): + continue + + for field_def in stage_def.get('config', []): + field_name = field_def.get('name') + field_type = field_def.get('type') + if not field_name or not field_type or field_name not in stage_config: + continue + + old_value = stage_config[field_name] + try: + new_value = _coerce_value(old_value, field_type) + if new_value is not old_value: + stage_config[field_name] = new_value + except (ValueError, TypeError) as e: + logger.warning( + 'Failed to coerce config %s.%s.%s (%r) to %s: %s', + section_name, + stage_name, + field_name, + old_value, + field_type, + e, + ) diff --git a/src/langbot/pkg/pipeline/controller.py b/src/langbot/pkg/pipeline/controller.py new file mode 100644 index 0000000..09d18a5 --- /dev/null +++ b/src/langbot/pkg/pipeline/controller.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import asyncio +import traceback + +from ..core import app +from ..core import entities as core_entities + +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +class Controller: + """总控制器""" + + ap: app.Application + + semaphore: asyncio.Semaphore = None + """请求并发控制信号量""" + + def __init__(self, ap: app.Application): + self.ap = ap + self.semaphore = asyncio.Semaphore(self.ap.instance_config.data['concurrency']['pipeline']) + + async def consumer(self): + """事件处理循环""" + try: + while True: + selected_query: pipeline_query.Query = None + + # 取请求 + async with self.ap.query_pool: + queries: list[pipeline_query.Query] = self.ap.query_pool.queries + + for query in queries: + session = await self.ap.sess_mgr.get_session(query) + # Debug logging removed from tight loop to prevent excessive log generation + # that can cause memory overflow in high-traffic scenarios + + if not session._semaphore.locked(): + selected_query = query + await session._semaphore.acquire() + # Only log when actually selecting a query + self.ap.logger.debug(f'Selected query {query.query_id} for processing') + + break + + if selected_query: # 找到了 + queries.remove(selected_query) + else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限 + await self.ap.query_pool.condition.wait() + continue + + if selected_query: + + async def _process_query(selected_query: pipeline_query.Query): + async with self.semaphore: # 总并发上限 + # find pipeline + # Here firstly find the bot, then find the pipeline, in case the bot adapter's config is not the latest one. + # Like aiocqhttp, once a client is connected, even the adapter was updated and restarted, the existing client connection will not be affected. + pipeline_uuid = selected_query.pipeline_uuid + + if pipeline_uuid: + pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid) + if pipeline: + await pipeline.run(selected_query) + else: + self.ap.logger.warning( + f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped' + ) + else: + self.ap.logger.warning( + f'No pipeline_uuid for query {selected_query.query_id}, query dropped' + ) + + async with self.ap.query_pool: + (await self.ap.sess_mgr.get_session(selected_query))._semaphore.release() + # 通知其他协程,有新的请求可以处理了 + self.ap.query_pool.condition.notify_all() + + self.ap.task_mgr.create_task( + _process_query(selected_query), + kind='query', + name=f'query-{selected_query.query_id}', + scopes=[ + core_entities.LifecycleControlScope.APPLICATION, + core_entities.LifecycleControlScope.PLATFORM, + ], + ) + + except Exception as e: + # traceback.print_exc() + self.ap.logger.error(f'控制器循环出错: {e}') + self.ap.logger.error(f'Traceback: {traceback.format_exc()}') + + async def run(self): + """运行控制器""" + await self.consumer() diff --git a/src/langbot/pkg/pipeline/entities.py b/src/langbot/pkg/pipeline/entities.py new file mode 100644 index 0000000..5426685 --- /dev/null +++ b/src/langbot/pkg/pipeline/entities.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import enum +import typing + +import pydantic + +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +class ResultType(enum.Enum): + CONTINUE = enum.auto() + """继续流水线""" + + INTERRUPT = enum.auto() + """中断流水线""" + + +class StageProcessResult(pydantic.BaseModel): + result_type: ResultType + + new_query: pipeline_query.Query + + user_notice: typing.Optional[ + typing.Union[ + str, + list[platform_message.MessageComponent], + platform_message.MessageChain, + None, + ] + ] = [] + """只要设置了就会发送给用户""" + + console_notice: typing.Optional[str] = '' + """只要设置了就会输出到控制台""" + + debug_notice: typing.Optional[str] = '' + + error_notice: typing.Optional[str] = '' diff --git a/src/langbot/pkg/pipeline/longtext/__init__.py b/src/langbot/pkg/pipeline/longtext/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/longtext/longtext.py b/src/langbot/pkg/pipeline/longtext/longtext.py new file mode 100644 index 0000000..32f0d41 --- /dev/null +++ b/src/langbot/pkg/pipeline/longtext/longtext.py @@ -0,0 +1,101 @@ +from __future__ import annotations +import os +import traceback + + +from . import strategy +from .. import stage, entities +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from ...utils import importutil +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +from . import strategies + +importutil.import_modules_in_pkg(strategies) + + +@stage.stage_class('LongTextProcessStage') +class LongTextProcessStage(stage.PipelineStage): + """Long message processing stage + + Rewrite: + - resp_message_chain + """ + + strategy_impl: strategy.LongTextStrategy | None + + async def initialize(self, pipeline_config: dict): + config = pipeline_config['output']['long-text-processing'] + + if config['strategy'] == 'none': + self.strategy_impl = None + return + + if config['strategy'] == 'image': + use_font = config['font-path'] + try: + # 检查是否存在 + if not os.path.exists(use_font): + # 若是windows系统,使用微软雅黑 + if os.name == 'nt': + use_font = 'C:/Windows/Fonts/msyh.ttc' + if not os.path.exists(use_font): + self.ap.logger.warn( + 'Font file not found, and Windows system font cannot be used, switch to forward message component to send long messages, you can adjust the related settings in the configuration file.' + ) + config['blob_message_strategy'] = 'forward' + else: + self.ap.logger.info('Using Windows system font: ' + use_font) + config['font-path'] = use_font + else: + self.ap.logger.warn( + 'Font file not found, and system font cannot be used, switch to forward message component to send long messages, you can adjust the related settings in the configuration file.' + ) + + pipeline_config['output']['long-text-processing']['strategy'] = 'forward' + except Exception: + traceback.print_exc() + self.ap.logger.error( + 'Failed to load font file ({}), switch to forward message component to send long messages, you can adjust the related settings in the configuration file.'.format( + use_font + ) + ) + + pipeline_config['output']['long-text-processing']['strategy'] = 'forward' + + for strategy_cls in strategy.preregistered_strategies: + if strategy_cls.name == config['strategy']: + self.strategy_impl = strategy_cls(self.ap) + break + else: + raise ValueError(f'Long message processing strategy not found: {config["strategy"]}') + + await self.strategy_impl.initialize() + + async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: + if self.strategy_impl is None: + self.ap.logger.debug('Long message processing strategy is not set, skip long message processing.') + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + + if not query.resp_message_chain: + self.ap.logger.debug('Response message chain is empty, skip long message processing.') + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + + # 检查是否包含非 Plain 组件 + contains_non_plain = False + + for msg in query.resp_message_chain[-1]: + if not isinstance(msg, platform_message.Plain): + contains_non_plain = True + break + + if contains_non_plain: + self.ap.logger.debug('Message contains non-Plain components, skip long message processing.') + elif ( + len(str(query.resp_message_chain[-1])) + > query.pipeline_config['output']['long-text-processing']['threshold'] + ): + query.resp_message_chain[-1] = platform_message.MessageChain( + await self.strategy_impl.process(str(query.resp_message_chain[-1]), query) + ) + + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/longtext/strategies/__init__.py b/src/langbot/pkg/pipeline/longtext/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/longtext/strategies/forward.py b/src/langbot/pkg/pipeline/longtext/strategies/forward.py new file mode 100644 index 0000000..1553ea2 --- /dev/null +++ b/src/langbot/pkg/pipeline/longtext/strategies/forward.py @@ -0,0 +1,35 @@ +# 转发消息组件 +from __future__ import annotations + + +from .. import strategy as strategy_model + +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.platform.message as platform_message + +ForwardMessageDiaplay = platform_message.ForwardMessageDiaplay +Forward = platform_message.Forward + + +@strategy_model.strategy_class('forward') +class ForwardComponentStrategy(strategy_model.LongTextStrategy): + async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]: + display = ForwardMessageDiaplay( + title='Group chat history', + brief='[Chat history]', + source='Chat history', + preview=['User: ' + message], + summary='View 1 forwarded message', + ) + + node_list = [ + platform_message.ForwardMessageNode( + sender_id=query.adapter.bot_account_id, + sender_name='User', + message_chain=platform_message.MessageChain([platform_message.Plain(text=message)]), + ) + ] + + forward = Forward(display=display, node_list=node_list) + + return [forward] diff --git a/src/langbot/pkg/pipeline/longtext/strategies/image.py b/src/langbot/pkg/pipeline/longtext/strategies/image.py new file mode 100644 index 0000000..110f1f8 --- /dev/null +++ b/src/langbot/pkg/pipeline/longtext/strategies/image.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import os +import base64 +import time +import re + +from PIL import Image, ImageDraw, ImageFont + +import functools + +from .. import strategy as strategy_model +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +@strategy_model.strategy_class('image') +class Text2ImageStrategy(strategy_model.LongTextStrategy): + async def initialize(self): + pass + + @functools.lru_cache(maxsize=16) + def get_font(self, font_path: str): + return ImageFont.truetype( + font_path, + 32, + encoding='utf-8', + ) + + async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]: + img_path = self.text_to_image( + text_str=message, + save_as='temp/{}.png'.format(int(time.time())), + query=query, + ) + + compressed_path, size = self.compress_image(img_path, outfile='temp/{}_compressed.png'.format(int(time.time()))) + + with open(compressed_path, 'rb') as f: + img = f.read() + + b64 = base64.b64encode(img) + + # 删除图片 + os.remove(img_path) + + if os.path.exists(compressed_path): + os.remove(compressed_path) + + return [ + platform_message.Image( + base64=b64.decode('utf-8'), + ) + ] + + def indexNumber(self, path=''): + """ + 查找字符串中数字所在串中的位置 + :param path:目标字符串 + :return:: : [['1', 16], ['2', 35], ['1', 51]] + """ + kv = [] + nums = [] + beforeDatas = re.findall('[\\d]+', path) + for num in beforeDatas: + indexV = [] + times = path.count(num) + if times > 1: + if num not in nums: + indexs = re.finditer(num, path) + for index in indexs: + iV = [] + i = index.span()[0] + iV.append(num) + iV.append(i) + kv.append(iV) + nums.append(num) + else: + index = path.find(num) + indexV.append(num) + indexV.append(index) + kv.append(indexV) + # 根据数字位置排序 + indexSort = [] + resultIndex = [] + for vi in kv: + indexSort.append(vi[1]) + indexSort.sort() + for i in indexSort: + for v in kv: + if i == v[1]: + resultIndex.append(v) + return resultIndex + + def get_size(self, file): + # 获取文件大小:KB + size = os.path.getsize(file) + return size / 1024 + + def get_outfile(self, infile, outfile): + if outfile: + return outfile + dir, suffix = os.path.splitext(infile) + outfile = '{}-out{}'.format(dir, suffix) + return outfile + + def compress_image(self, infile, outfile='', kb=100, step=20, quality=90): + """不改变图片尺寸压缩到指定大小 + :param infile: 压缩源文件 + :param outfile: 压缩文件保存地址 + :param mb: 压缩目标,KB + :param step: 每次调整的压缩比率 + :param quality: 初始压缩比率 + :return: 压缩文件地址,压缩文件大小 + """ + o_size = self.get_size(infile) + if o_size <= kb: + return infile, o_size + outfile = self.get_outfile(infile, outfile) + while o_size > kb: + im = Image.open(infile) + im.save(outfile, quality=quality) + if quality - step < 0: + break + quality -= step + o_size = self.get_size(outfile) + return outfile, self.get_size(outfile) + + def text_to_image( + self, + text_str: str, + save_as='temp.png', + width=800, + query: pipeline_query.Query = None, + ): + text_str = text_str.replace('\t', ' ') + + # 分行 + lines = text_str.split('\n') + + # 计算并分割 + final_lines = [] + + text_width = width - 80 + + self.ap.logger.debug('lines: {}, text_width: {}'.format(lines, text_width)) + for line in lines: + # 如果长了就分割 + line_width = self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']).getlength( + line + ) + self.ap.logger.debug('line_width: {}'.format(line_width)) + if line_width < text_width: + final_lines.append(line) + continue + else: + rest_text = line + while True: + # 分割最前面的一行 + point = int(len(rest_text) * (text_width / line_width)) + + # 检查断点是否在数字中间 + numbers = self.indexNumber(rest_text) + + for number in numbers: + if number[1] < point < number[1] + len(number[0]) and number[1] != 0: + point = number[1] + break + + final_lines.append(rest_text[:point]) + rest_text = rest_text[point:] + line_width = self.get_font( + query.pipeline_config['output']['long-text-processing']['font-path'] + ).getlength(rest_text) + if line_width < text_width: + final_lines.append(rest_text) + break + else: + continue + # 准备画布 + img = Image.new('RGBA', (width, max(280, len(final_lines) * 35 + 65)), (255, 255, 255, 255)) + draw = ImageDraw.Draw(img, mode='RGBA') + + self.ap.logger.debug('正在绘制图片...') + # 绘制正文 + line_number = 0 + offset_x = 20 + offset_y = 30 + for final_line in final_lines: + draw.text( + (offset_x, offset_y + 35 * line_number), + final_line, + fill=(0, 0, 0), + font=self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']), + ) + # 遍历此行,检查是否有emoji + idx_in_line = 0 + for ch in final_line: + # 检查字符占位宽 + char_code = ord(ch) + if char_code >= 127: + idx_in_line += 1 + else: + idx_in_line += 0.5 + + line_number += 1 + + self.ap.logger.debug('正在保存图片...') + img.save(save_as) + + return save_as diff --git a/src/langbot/pkg/pipeline/longtext/strategy.py b/src/langbot/pkg/pipeline/longtext/strategy.py new file mode 100644 index 0000000..cb8ce7e --- /dev/null +++ b/src/langbot/pkg/pipeline/longtext/strategy.py @@ -0,0 +1,65 @@ +from __future__ import annotations +import abc +import typing + + +from ...core import app + +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +preregistered_strategies: list[typing.Type[LongTextStrategy]] = [] + + +def strategy_class( + name: str, +) -> typing.Callable[[typing.Type[LongTextStrategy]], typing.Type[LongTextStrategy]]: + """Long text processing strategy class decorator + + Args: + name (str): Strategy name + + Returns: + typing.Callable[[typing.Type[LongTextStrategy]], typing.Type[LongTextStrategy]]: Decorator + """ + + def decorator(cls: typing.Type[LongTextStrategy]) -> typing.Type[LongTextStrategy]: + assert issubclass(cls, LongTextStrategy) + + cls.name = name + + preregistered_strategies.append(cls) + + return cls + + return decorator + + +class LongTextStrategy(metaclass=abc.ABCMeta): + """Long text processing strategy abstract class""" + + name: str + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def initialize(self): + pass + + @abc.abstractmethod + async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]: + """处理长文本 + + If the text length exceeds the threshold, this method will be called. + + Args: + message (str): Message + query (core_entities.Query): Query object + + Returns: + list[platform_message.MessageComponent]: Converted platform message components + """ + return [] diff --git a/src/langbot/pkg/pipeline/monitoring_helper.py b/src/langbot/pkg/pipeline/monitoring_helper.py new file mode 100644 index 0000000..a3a9654 --- /dev/null +++ b/src/langbot/pkg/pipeline/monitoring_helper.py @@ -0,0 +1,355 @@ +""" +Monitoring helper for recording events during pipeline execution. +This module provides convenient methods to record monitoring data +without cluttering the main pipeline code. +""" + +from __future__ import annotations + +import traceback +import typing +import time +import json + +if typing.TYPE_CHECKING: + from ..core import app + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +class MonitoringHelper: + """Helper class for monitoring operations""" + + @staticmethod + async def record_query_start( + ap: app.Application, + query: pipeline_query.Query, + bot_id: str, + bot_name: str, + pipeline_id: str, + pipeline_name: str, + runner_name: str | None = None, + ) -> str: + """Record the start of query processing, returns message_id""" + try: + # Check if session exists, if not, record session start + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' + + # Get sender name from message event + sender_name = None + if hasattr(query, 'message_event'): + if hasattr(query.message_event, 'sender'): + if hasattr(query.message_event.sender, 'nickname'): + sender_name = query.message_event.sender.nickname + elif hasattr(query.message_event.sender, 'member_name'): + sender_name = query.message_event.sender.member_name + + # Try to record message + # Use JSON serialization to preserve message chain structure (including image URLs, etc.) + if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'): + message_content = json.dumps(query.message_chain.model_dump(), ensure_ascii=False) + else: + message_content = str(query) + + # Variables will be updated in record_query_success after preproc stage sets them + # Here we just record None, the full variables will be set when query completes + + message_id = await ap.monitoring_service.record_message( + bot_id=bot_id, + bot_name=bot_name, + pipeline_id=pipeline_id, + pipeline_name=pipeline_name, + message_content=message_content, + session_id=session_id, + status='pending', + level='info', + platform=query.launcher_type.value + if hasattr(query.launcher_type, 'value') + else str(query.launcher_type), + user_id=query.sender_id, + user_name=sender_name, + runner_name=runner_name, + variables=None, # Will be updated in record_query_success + ) + + # Update session activity or create new session if it doesn't exist + # Always pass pipeline info to handle pipeline switches + session_updated = await ap.monitoring_service.update_session_activity( + session_id, + pipeline_id=pipeline_id, + pipeline_name=pipeline_name, + ) + if not session_updated: + # Session doesn't exist, create it + await ap.monitoring_service.record_session_start( + session_id=session_id, + bot_id=bot_id, + bot_name=bot_name, + pipeline_id=pipeline_id, + pipeline_name=pipeline_name, + platform=query.launcher_type.value + if hasattr(query.launcher_type, 'value') + else str(query.launcher_type), + user_id=query.sender_id, + user_name=sender_name, + ) + + return message_id + except Exception as e: + ap.logger.error(f'Failed to record query start: {e}') + return '' + + @staticmethod + async def record_query_success( + ap: app.Application, + message_id: str, + query: pipeline_query.Query | None = None, + ): + """Record successful query processing by updating message status and variables""" + try: + if message_id: + # Serialize query.variables (filtering out internal variables) + query_variables_str = None + if query and hasattr(query, 'variables') and query.variables: + filtered_vars = {k: v for k, v in query.variables.items() if not k.startswith('_')} + if filtered_vars: + try: + query_variables_str = json.dumps(filtered_vars, ensure_ascii=False, default=str) + except Exception: + pass + + await ap.monitoring_service.update_message_status( + message_id=message_id, + status='success', + variables=query_variables_str, + ) + except Exception as e: + ap.logger.error(f'Failed to record query success: {e}') + + @staticmethod + async def record_query_response( + ap: app.Application, + query: pipeline_query.Query, + bot_id: str, + bot_name: str, + pipeline_id: str, + pipeline_name: str, + runner_name: str | None = None, + ): + """Record bot response message to monitoring""" + try: + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' + + # Get sender name from message event + sender_name = None + if hasattr(query, 'message_event'): + if hasattr(query.message_event, 'sender'): + if hasattr(query.message_event.sender, 'nickname'): + sender_name = query.message_event.sender.nickname + elif hasattr(query.message_event.sender, 'member_name'): + sender_name = query.message_event.sender.member_name + + # Extract response content from resp_message_chain + if hasattr(query, 'resp_message_chain') and query.resp_message_chain: + # Serialize the last response message chain + last_resp = query.resp_message_chain[-1] + if hasattr(last_resp, 'model_dump'): + message_content = json.dumps(last_resp.model_dump(), ensure_ascii=False) + else: + message_content = str(last_resp) + elif hasattr(query, 'resp_messages') and query.resp_messages: + last_resp = query.resp_messages[-1] + if hasattr(last_resp, 'get_content_platform_message_chain'): + chain = last_resp.get_content_platform_message_chain() + if hasattr(chain, 'model_dump'): + message_content = json.dumps(chain.model_dump(), ensure_ascii=False) + else: + message_content = str(chain) + else: + message_content = str(last_resp) + else: + return # No response to record + + await ap.monitoring_service.record_message( + bot_id=bot_id, + bot_name=bot_name, + pipeline_id=pipeline_id, + pipeline_name=pipeline_name, + message_content=message_content, + session_id=session_id, + status='success', + level='info', + platform=query.launcher_type.value + if hasattr(query.launcher_type, 'value') + else str(query.launcher_type), + user_id=query.sender_id, + user_name=sender_name, + runner_name=runner_name, + role='assistant', + ) + except Exception as e: + ap.logger.error(f'Failed to record query response: {e}') + + @staticmethod + async def record_query_error( + ap: app.Application, + query: pipeline_query.Query, + bot_id: str, + bot_name: str, + pipeline_id: str, + pipeline_name: str, + error: Exception, + runner_name: str | None = None, + ) -> str: + """Record query processing error, returns message_id""" + try: + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' + + # Get sender name from message event + sender_name = None + if hasattr(query, 'message_event'): + if hasattr(query.message_event, 'sender'): + if hasattr(query.message_event.sender, 'nickname'): + sender_name = query.message_event.sender.nickname + elif hasattr(query.message_event.sender, 'member_name'): + sender_name = query.message_event.sender.member_name + + # Record error message + message_id = await ap.monitoring_service.record_message( + bot_id=bot_id, + bot_name=bot_name, + pipeline_id=pipeline_id, + pipeline_name=pipeline_name, + message_content=f'Error: {str(error)}', + session_id=session_id, + status='error', + level='error', + platform=query.launcher_type.value + if hasattr(query.launcher_type, 'value') + else str(query.launcher_type), + user_id=query.sender_id, + user_name=sender_name, + runner_name=runner_name, + ) + + # Record error log + await ap.monitoring_service.record_error( + bot_id=bot_id, + bot_name=bot_name, + pipeline_id=pipeline_id, + pipeline_name=pipeline_name, + error_type=type(error).__name__, + error_message=str(error), + session_id=session_id, + stack_trace=traceback.format_exc(), + message_id=message_id, + ) + + return message_id + except Exception as e: + ap.logger.error(f'Failed to record query error: {e}') + return '' + + @staticmethod + async def record_llm_call( + ap: app.Application, + query: pipeline_query.Query, + bot_id: str, + bot_name: str, + pipeline_id: str, + pipeline_name: str, + model_name: str, + input_tokens: int, + output_tokens: int, + duration_ms: int, + status: str = 'success', + cost: float | None = None, + error_message: str | None = None, + message_id: str | None = None, + ): + """Record LLM call""" + try: + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' + + await ap.monitoring_service.record_llm_call( + bot_id=bot_id, + bot_name=bot_name, + pipeline_id=pipeline_id, + pipeline_name=pipeline_name, + session_id=session_id, + model_name=model_name, + input_tokens=input_tokens, + output_tokens=output_tokens, + duration=duration_ms, + status=status, + cost=cost, + error_message=error_message, + message_id=message_id, + ) + except Exception as e: + ap.logger.error(f'Failed to record LLM call: {e}') + + +class LLMCallMonitor: + """Context manager for monitoring LLM calls""" + + def __init__( + self, + ap: app.Application, + query: pipeline_query.Query, + bot_id: str, + bot_name: str, + pipeline_id: str, + pipeline_name: str, + model_name: str, + ): + self.ap = ap + self.query = query + self.bot_id = bot_id + self.bot_name = bot_name + self.pipeline_id = pipeline_id + self.pipeline_name = pipeline_name + self.model_name = model_name + self.start_time = None + self.input_tokens = 0 + self.output_tokens = 0 + + async def __aenter__(self): + self.start_time = time.time() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + duration_ms = int((time.time() - self.start_time) * 1000) + + if exc_type is not None: + # Error occurred + await MonitoringHelper.record_llm_call( + ap=self.ap, + query=self.query, + bot_id=self.bot_id, + bot_name=self.bot_name, + pipeline_id=self.pipeline_id, + pipeline_name=self.pipeline_name, + model_name=self.model_name, + input_tokens=self.input_tokens, + output_tokens=self.output_tokens, + duration_ms=duration_ms, + status='error', + error_message=str(exc_val) if exc_val else None, + ) + else: + # Success + await MonitoringHelper.record_llm_call( + ap=self.ap, + query=self.query, + bot_id=self.bot_id, + bot_name=self.bot_name, + pipeline_id=self.pipeline_id, + pipeline_name=self.pipeline_name, + model_name=self.model_name, + input_tokens=self.input_tokens, + output_tokens=self.output_tokens, + duration_ms=duration_ms, + status='success', + ) + + return False # Don't suppress exceptions diff --git a/src/langbot/pkg/pipeline/msgtrun/__init__.py b/src/langbot/pkg/pipeline/msgtrun/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/msgtrun/msgtrun.py b/src/langbot/pkg/pipeline/msgtrun/msgtrun.py new file mode 100644 index 0000000..00a9bfb --- /dev/null +++ b/src/langbot/pkg/pipeline/msgtrun/msgtrun.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from .. import stage, entities +from . import truncator +from ...utils import importutil +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +from . import truncators + +importutil.import_modules_in_pkg(truncators) + + +@stage.stage_class('ConversationMessageTruncator') +class ConversationMessageTruncator(stage.PipelineStage): + """Conversation message truncator + + Used to truncate the conversation message chain to adapt to the LLM message length limit. + """ + + trun: truncator.Truncator + + async def initialize(self, pipeline_config: dict): + use_method = 'round' + + for trun in truncator.preregistered_truncators: + if trun.name == use_method: + self.trun = trun(self.ap) + break + else: + raise ValueError(f'Unknown truncator: {use_method}') + + async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: + """处理""" + query = await self.trun.truncate(query) + + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/msgtrun/truncator.py b/src/langbot/pkg/pipeline/msgtrun/truncator.py new file mode 100644 index 0000000..180982d --- /dev/null +++ b/src/langbot/pkg/pipeline/msgtrun/truncator.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import typing +import abc + +from ...core import app +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + +preregistered_truncators: list[typing.Type[Truncator]] = [] + + +def truncator_class( + name: str, +) -> typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: + """截断器类装饰器 + + Args: + name (str): 截断器名称 + + Returns: + typing.Callable[[typing.Type[Truncator]], typing.Type[Truncator]]: 装饰器 + """ + + def decorator(cls: typing.Type[Truncator]) -> typing.Type[Truncator]: + assert issubclass(cls, Truncator) + + cls.name = name + + preregistered_truncators.append(cls) + + return cls + + return decorator + + +class Truncator(abc.ABC): + """消息截断器基类""" + + name: str + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def initialize(self): + pass + + @abc.abstractmethod + async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query: + """截断 + + 一般只需要操作query.messages,也可以扩展操作query.prompt, query.user_message。 + 请勿操作其他字段。 + """ + pass diff --git a/src/langbot/pkg/pipeline/msgtrun/truncators/__init__.py b/src/langbot/pkg/pipeline/msgtrun/truncators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/msgtrun/truncators/round.py b/src/langbot/pkg/pipeline/msgtrun/truncators/round.py new file mode 100644 index 0000000..400706b --- /dev/null +++ b/src/langbot/pkg/pipeline/msgtrun/truncators/round.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from .. import truncator +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@truncator.truncator_class('round') +class RoundTruncator(truncator.Truncator): + """Truncate the conversation message chain to adapt to the LLM message length limit.""" + + async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query: + """截断""" + max_round = query.pipeline_config['ai']['local-agent']['max-round'] + + temp_messages = [] + + current_round = 0 + + # Traverse from back to front + for msg in query.messages[::-1]: + if current_round < max_round: + temp_messages.append(msg) + if msg.role == 'user': + current_round += 1 + else: + break + + query.messages = temp_messages[::-1] + + return query diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py new file mode 100644 index 0000000..ef189be --- /dev/null +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import typing +import traceback + +import sqlalchemy + +from ..core import app +from . import entities as pipeline_entities +from ..entity.persistence import pipeline as persistence_pipeline +from . import stage +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.events as events +from ..utils import importutil +from .config_coercion import coerce_pipeline_config + +import langbot_plugin.api.entities.builtin.provider.session as provider_session +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + +from . import ( + resprule, + bansess, + cntfilter, + process, + longtext, + respback, + wrapper, + preproc, + ratelimit, + msgtrun, +) + +importutil.import_modules_in_pkgs( + [ + resprule, + bansess, + cntfilter, + process, + longtext, + respback, + wrapper, + preproc, + ratelimit, + msgtrun, + ] +) + + +class StageInstContainer: + """阶段实例容器""" + + inst_name: str + + inst: stage.PipelineStage + + def __init__(self, inst_name: str, inst: stage.PipelineStage): + self.inst_name = inst_name + self.inst = inst + + +class RuntimePipeline: + """运行时流水线""" + + ap: app.Application + + pipeline_entity: persistence_pipeline.LegacyPipeline + """流水线实体""" + + stage_containers: list[StageInstContainer] + """阶段实例容器""" + + bound_plugins: list[str] | None + """绑定到此流水线的插件列表(格式:author/plugin_name),None表示启用所有""" + + bound_mcp_servers: list[str] | None + """绑定到此流水线的MCP服务器列表(格式:uuid),None表示启用所有""" + + enable_all_plugins: bool + """是否启用所有插件""" + + enable_all_mcp_servers: bool + """是否启用所有MCP服务器""" + + def __init__( + self, + ap: app.Application, + pipeline_entity: persistence_pipeline.LegacyPipeline, + stage_containers: list[StageInstContainer], + ): + self.ap = ap + self.pipeline_entity = pipeline_entity + self.stage_containers = stage_containers + + # Extract bound plugins and MCP servers from extensions_preferences + extensions_prefs = pipeline_entity.extensions_preferences or {} + self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True) + self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True) + local_agent_config = (pipeline_entity.config or {}).get('ai', {}).get('local-agent', {}) + self.mcp_resource_attachments = local_agent_config.get( + 'mcp-resources', + extensions_prefs.get('mcp_resources', []), + ) + self.mcp_resource_agent_read_enabled = local_agent_config.get( + 'mcp-resource-agent-read-enabled', + extensions_prefs.get('mcp_resource_agent_read_enabled', True), + ) + + if self.enable_all_plugins: + # None indicates to use all available plugins + self.bound_plugins = None + else: + plugin_list = extensions_prefs.get('plugins', []) + self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if plugin_list else [] + + if self.enable_all_mcp_servers: + # None indicates to use all available MCP servers + self.bound_mcp_servers = None + else: + mcp_server_list = extensions_prefs.get('mcp_servers', []) + self.bound_mcp_servers = mcp_server_list if mcp_server_list else [] + + async def run(self, query: pipeline_query.Query): + query.pipeline_config = self.pipeline_entity.config + # Store bound plugins and MCP servers in query for filtering + query.variables['_pipeline_bound_plugins'] = self.bound_plugins + query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers + query.variables['_pipeline_mcp_resource_attachments'] = self.mcp_resource_attachments + query.variables['_pipeline_mcp_resource_agent_read_enabled'] = self.mcp_resource_agent_read_enabled + + # Record query start for monitoring + try: + # Get bot name from bot_uuid + bot_name = 'WebChat' + if query.bot_uuid: + try: + bot = await self.ap.bot_service.get_bot(query.bot_uuid, include_secret=False) + if bot: + bot_name = bot.get('name', 'Unknown') + except Exception: + pass + + # Store for later use in process_query + query.variables['_monitoring_bot_name'] = bot_name + query.variables['_monitoring_pipeline_name'] = self.pipeline_entity.name + except Exception as e: + self.ap.logger.error(f'Failed to prepare monitoring data: {e}') + + await self.process_query(query) + + async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult): + """检查输出""" + if result.user_notice: + # 处理str类型 + + if isinstance(result.user_notice, str): + result.user_notice = platform_message.MessageChain([platform_message.Plain(text=result.user_notice)]) + elif isinstance(result.user_notice, list): + result.user_notice = platform_message.MessageChain(*result.user_notice) + + if query.pipeline_config['output']['misc']['at-sender'] and isinstance( + query.message_event, platform_events.GroupMessage + ): + result.user_notice.insert(0, platform_message.At(target=query.message_event.sender.id)) + if await query.adapter.is_stream_output_supported() and query.resp_messages: + await query.adapter.reply_message_chunk( + message_source=query.message_event, + bot_message=query.resp_messages[-1], + message=result.user_notice, + quote_origin=query.pipeline_config['output']['misc']['quote-origin'], + is_final=[msg.is_final for msg in query.resp_messages][-1], + ) + else: + await query.adapter.reply_message( + message_source=query.message_event, + message=result.user_notice, + quote_origin=query.pipeline_config['output']['misc']['quote-origin'], + ) + if result.debug_notice: + self.ap.logger.debug(result.debug_notice) + if result.console_notice: + self.ap.logger.info(result.console_notice) + if result.error_notice: + self.ap.logger.error(result.error_notice) + # Mark query as having error + query.variables['_monitoring_has_error'] = True + # Record error to monitoring system + try: + bot_name = query.variables.get('_monitoring_bot_name', 'Unknown') + pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown') + message_id = query.variables.get('_monitoring_message_id', '') + session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}' + + # Update message status to error + if message_id: + await self.ap.monitoring_service.update_message_status( + message_id=message_id, + status='error', + level='error', + ) + + # Record error log + await self.ap.monitoring_service.record_error( + bot_id=query.bot_uuid or 'unknown', + bot_name=bot_name, + pipeline_id=self.pipeline_entity.uuid, + pipeline_name=pipeline_name, + error_type='PipelineError', + error_message=result.error_notice, + session_id=session_id, + stack_trace=result.debug_notice if result.debug_notice else None, + message_id=message_id, + ) + except Exception as e: + self.ap.logger.error(f'Failed to record error to monitoring: {e}') + + async def _execute_from_stage( + self, + stage_index: int, + query: pipeline_query.Query, + ): + """从指定阶段开始执行,实现了责任链模式和基于生成器的阶段分叉功能。 + + 如何看懂这里为什么这么写? + 去问 GPT-4: + Q1: 现在有一个责任链,其中有多个stage,query对象在其中传递,stage.process可能返回Result也有可能返回typing.AsyncGenerator[Result, None], + 如果返回的是生成器,需要挨个生成result,检查是否result中是否要求继续,如果要求继续就进行下一个stage。如果此次生成器产生的result处理完了,就继续生成下一个result, + 调用后续的stage,直到该生成器全部生成完。责任链中可能有多个stage会返回生成器 + Q2: 不是这样的,你可能理解有误。如果我们责任链上有这些Stage: + + A B C D E F G + + 如果所有的stage都返回Result,且所有Result都要求继续,那么执行顺序是: + + A B C D E F G + + 现在假设C返回的是AsyncGenerator,那么执行顺序是: + + A B C D E F G C D E F G C D E F G ... + Q3: 但是如果不止一个stage会返回生成器呢? + """ + i = stage_index + + while i < len(self.stage_containers): + stage_container = self.stage_containers[i] + + query.current_stage_name = stage_container.inst_name # 标记到 Query 对象里 + + result = stage_container.inst.process(query, stage_container.inst_name) + + if isinstance(result, typing.Coroutine): + result = await result + + if isinstance(result, pipeline_entities.StageProcessResult): # 直接返回结果 + self.ap.logger.debug( + f'Stage {stage_container.inst_name} processed query {query.query_id} res {result.result_type}' + ) + await self._check_output(query, result) + + if result.result_type == pipeline_entities.ResultType.INTERRUPT: + self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}') + break + elif result.result_type == pipeline_entities.ResultType.CONTINUE: + query = result.new_query + elif isinstance(result, typing.AsyncGenerator): # 生成器 + self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen') + + async for sub_result in result: + self.ap.logger.debug( + f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}' + ) + await self._check_output(query, sub_result) + + if sub_result.result_type == pipeline_entities.ResultType.INTERRUPT: + self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}') + break + elif sub_result.result_type == pipeline_entities.ResultType.CONTINUE: + query = sub_result.new_query + await self._execute_from_stage(i + 1, query) + break + + i += 1 + + async def process_query(self, query: pipeline_query.Query): + """处理请求""" + # Get monitoring metadata + bot_name = query.variables.get('_monitoring_bot_name', 'Unknown') + pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown') + + # Get runner name from pipeline config + runner_name = None + if query.pipeline_config and 'ai' in query.pipeline_config and 'runner' in query.pipeline_config['ai']: + runner_name = query.pipeline_config['ai']['runner'].get('runner') + + # Record query start and store message_id + message_id = '' + try: + from . import monitoring_helper + + message_id = await monitoring_helper.MonitoringHelper.record_query_start( + ap=self.ap, + query=query, + bot_id=query.bot_uuid or 'unknown', + bot_name=bot_name, + pipeline_id=self.pipeline_entity.uuid, + pipeline_name=pipeline_name, + runner_name=runner_name, + ) + # Store message_id in query variables for LLM call monitoring + query.variables['_monitoring_message_id'] = message_id + # Notify adapter so it can map platform-specific IDs to monitoring message ID + if hasattr(query.adapter, 'on_monitoring_message_created'): + await query.adapter.on_monitoring_message_created(query, message_id) + except Exception as e: + self.ap.logger.error(f'Failed to record query start: {e}') + + try: + # Get bound plugins for this pipeline + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + + # ======== 触发 MessageReceived 事件 ======== + event_type = ( + events.PersonMessageReceived + if query.launcher_type == provider_session.LauncherTypes.PERSON + else events.GroupMessageReceived + ) + + event_obj = event_type( + query=query, + launcher_type=query.launcher_type.value, + launcher_id=query.launcher_id, + sender_id=query.sender_id, + message_event=query.message_event, + message_chain=query.message_chain, + ) + + event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins) + + if event_ctx.is_prevented_default(): + self.ap.logger.debug( + f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}' + ) + return + + self.ap.logger.debug(f'Processing query {query.query_id}') + + await self._execute_from_stage(0, query) + + # Record query success only if no error occurred during processing + if not query.variables.get('_monitoring_has_error', False): + try: + await monitoring_helper.MonitoringHelper.record_query_success( + ap=self.ap, + message_id=message_id, + query=query, + ) + except Exception as e: + self.ap.logger.error(f'Failed to record query success: {e}') + + # Record bot response message + try: + await monitoring_helper.MonitoringHelper.record_query_response( + ap=self.ap, + query=query, + bot_id=query.bot_uuid or 'unknown', + bot_name=bot_name, + pipeline_id=self.pipeline_entity.uuid, + pipeline_name=pipeline_name, + runner_name=runner_name, + ) + except Exception as e: + self.ap.logger.error(f'Failed to record query response: {e}') + + except Exception as e: + inst_name = query.current_stage_name if query.current_stage_name else 'unknown' + self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}') + self.ap.logger.error(f'Traceback: {traceback.format_exc()}') + + # Record query error + try: + from . import monitoring_helper + + await monitoring_helper.MonitoringHelper.record_query_error( + ap=self.ap, + query=query, + bot_id=query.bot_uuid or 'unknown', + bot_name=bot_name, + pipeline_id=self.pipeline_entity.uuid, + pipeline_name=pipeline_name, + error=e, + runner_name=runner_name, + ) + except Exception as me: + self.ap.logger.error(f'Failed to record query error: {me}') + + finally: + self.ap.logger.debug(f'Query {query.query_id} processed') + del self.ap.query_pool.cached_queries[query.query_id] + + +class PipelineManager: + """流水线管理器""" + + ap: app.Application + + pipelines: list[RuntimePipeline] + + stage_dict: dict[str, type[stage.PipelineStage]] + + def __init__(self, ap: app.Application): + self.ap = ap + self.pipelines = [] + + async def initialize(self): + self.stage_dict = {name: cls for name, cls in stage.preregistered_stages.items()} + + await self.load_pipelines_from_db() + + async def load_pipelines_from_db(self): + self.ap.logger.info('Loading pipelines from db...') + + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) + + pipelines = result.all() + + # load pipelines + for pipeline in pipelines: + await self.load_pipeline(pipeline) + + async def load_pipeline( + self, + pipeline_entity: persistence_pipeline.LegacyPipeline + | sqlalchemy.Row[persistence_pipeline.LegacyPipeline] + | dict, + ): + if isinstance(pipeline_entity, sqlalchemy.Row): + pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity._mapping) + elif isinstance(pipeline_entity, dict): + pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity) + + coerce_pipeline_config( + pipeline_entity.config, + getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}), + getattr(self.ap, 'pipeline_config_meta_safety', {'name': 'safety', 'stages': []}), + getattr(self.ap, 'pipeline_config_meta_ai', {'name': 'ai', 'stages': []}), + getattr(self.ap, 'pipeline_config_meta_output', {'name': 'output', 'stages': []}), + ) + + # initialize stage containers according to pipeline_entity.stages + stage_containers: list[StageInstContainer] = [] + for stage_name in pipeline_entity.stages: + stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap))) + + for stage_container in stage_containers: + await stage_container.inst.initialize(pipeline_entity.config) + + runtime_pipeline = RuntimePipeline(self.ap, pipeline_entity, stage_containers) + self.pipelines.append(runtime_pipeline) + + async def get_pipeline_by_uuid(self, uuid: str) -> RuntimePipeline | None: + for pipeline in self.pipelines: + if pipeline.pipeline_entity.uuid == uuid: + return pipeline + return None + + async def remove_pipeline(self, uuid: str): + for pipeline in self.pipelines: + if pipeline.pipeline_entity.uuid == uuid: + self.pipelines.remove(pipeline) + return diff --git a/src/langbot/pkg/pipeline/plugin_diagnostics.py b/src/langbot/pkg/pipeline/plugin_diagnostics.py new file mode 100644 index 0000000..3e1195c --- /dev/null +++ b/src/langbot/pkg/pipeline/plugin_diagnostics.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import traceback +import weakref +from dataclasses import dataclass, field +from typing import Any + +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +@dataclass(frozen=True) +class PluginResponseSource: + plugin: dict[str, str] + event_name: str | None = None + is_approximate: bool = False + + +@dataclass +class QueryDiagnosticState: + pending_by_chain_id: dict[int, list[PluginResponseSource]] = field(default_factory=dict) + by_response_index: dict[int, list[PluginResponseSource]] = field(default_factory=dict) + finalizer: weakref.finalize | None = None + + +_QUERY_STATES: dict[int, QueryDiagnosticState] = {} + + +def record_plugin_response_source( + query: pipeline_query.Query, + response_index: int, + response_sources: list[dict[str, Any]] | None, + emitted_plugins: list[dict[str, Any]] | None = None, + event_name: str | None = None, +) -> None: + plugin_sources = _build_plugin_sources(response_sources, emitted_plugins, event_name) + if not plugin_sources: + return + state = _get_or_create_query_state(query) + state.by_response_index[response_index] = plugin_sources + + +def record_last_plugin_response_source( + query: pipeline_query.Query, + response_sources: list[dict[str, Any]] | None, + emitted_plugins: list[dict[str, Any]] | None = None, + event_name: str | None = None, +) -> None: + record_plugin_response_source( + query, + len(query.resp_message_chain) - 1, + response_sources, + emitted_plugins, + event_name, + ) + + +def record_pending_plugin_response_source( + query: pipeline_query.Query, + message_chain: platform_message.MessageChain, + response_sources: list[dict[str, Any]] | None, + emitted_plugins: list[dict[str, Any]] | None = None, + event_name: str | None = None, +) -> None: + plugin_sources = _build_plugin_sources(response_sources, emitted_plugins, event_name) + if not plugin_sources: + return + state = _get_or_create_query_state(query) + state.pending_by_chain_id[id(message_chain)] = plugin_sources + + +def consume_pending_plugin_response_source( + query: pipeline_query.Query, + message_chain: platform_message.MessageChain, + response_index: int, +) -> None: + state = _get_query_state(query) + if state is None: + return + source = state.pending_by_chain_id.pop(id(message_chain), None) + if source is None: + return + state.by_response_index[response_index] = source + + +def clear_response_source(query: pipeline_query.Query, response_index: int) -> None: + state = _get_query_state(query) + if state is None: + return + state.by_response_index.pop(response_index, None) + _discard_query_state_if_empty(query) + + +async def notify_response_delivery_failure( + ap: Any, + query: pipeline_query.Query, + response_index: int, + message_chain: platform_message.MessageChain, + error: Exception, +) -> None: + try: + plugin_refs = _get_response_sources(query, response_index) + if not plugin_refs: + return + connector = getattr(ap, 'plugin_connector', None) + if connector is None or not hasattr(connector, 'notify_plugin_diagnostic'): + return + for source in plugin_refs: + payload = _build_delivery_failure_payload( + plugin_ref=source.plugin, + event_name=source.event_name, + is_approximate=source.is_approximate, + query=query, + response_index=response_index, + message_chain=message_chain, + error=error, + ) + try: + await connector.notify_plugin_diagnostic(payload) + except Exception as diag_error: + _debug(ap, f'Plugin diagnostic forwarding failed: {diag_error}') + except Exception as diag_error: + _debug(ap, f'Plugin diagnostic forwarding skipped: {diag_error}') + + +def get_emitted_plugins(event_ctx: Any) -> list[dict[str, Any]]: + emitted_plugins = getattr(event_ctx, '_emitted_plugins', []) + return emitted_plugins if isinstance(emitted_plugins, list) else [] + + +def get_response_sources(event_ctx: Any) -> list[dict[str, Any]] | None: + event_attrs = vars(event_ctx) + if '_response_sources' not in event_attrs: + return None + response_sources = event_attrs['_response_sources'] + return response_sources if isinstance(response_sources, list) else [] + + +def _get_or_create_query_state(query: pipeline_query.Query) -> QueryDiagnosticState: + query_key = id(query) + state = _QUERY_STATES.get(query_key) + if state is not None: + return state + + state = QueryDiagnosticState() + try: + state.finalizer = weakref.finalize(query, _discard_query_state, query_key) + except TypeError: + state.finalizer = None + _QUERY_STATES[query_key] = state + return state + + +def _get_query_state(query: pipeline_query.Query) -> QueryDiagnosticState | None: + return _QUERY_STATES.get(id(query)) + + +def _discard_query_state(query_key: int) -> None: + _QUERY_STATES.pop(query_key, None) + + +def _discard_query_state_if_empty(query: pipeline_query.Query) -> None: + query_key = id(query) + state = _QUERY_STATES.get(query_key) + if state is None: + return + if state.pending_by_chain_id or state.by_response_index: + return + if state.finalizer is not None: + state.finalizer.detach() + _discard_query_state(query_key) + + +def _get_response_sources( + query: pipeline_query.Query, + response_index: int, +) -> list[PluginResponseSource]: + state = _get_query_state(query) + if state is None: + return [] + return state.by_response_index.get(response_index, []) + + +def _extract_plugin_ref(plugin: Any) -> dict[str, str] | None: + manifest = plugin.get('manifest') if isinstance(plugin, dict) else None + metadata = manifest.get('metadata') if isinstance(manifest, dict) else None + if not isinstance(metadata, dict): + return None + author = metadata.get('author') + name = metadata.get('name') + if not author or not name: + return None + return {'author': str(author), 'name': str(name)} + + +def _extract_response_source_plugin_ref(source: Any) -> dict[str, str] | None: + if not isinstance(source, dict): + return None + if source.get('kind') != 'reply_message_chain': + return None + plugin_ref = source.get('plugin') + if not isinstance(plugin_ref, dict): + return None + author = plugin_ref.get('author') + name = plugin_ref.get('name') + if not author or not name: + return None + return {'author': str(author), 'name': str(name)} + + +def _build_plugin_sources( + response_sources: list[dict[str, Any]] | None, + emitted_plugins: list[dict[str, Any]] | None, + event_name: str | None, +) -> list[PluginResponseSource]: + if response_sources is not None: + plugin_refs = [_extract_response_source_plugin_ref(source) for source in response_sources] + return [ + PluginResponseSource(plugin=plugin, event_name=event_name) for plugin in plugin_refs if plugin is not None + ] + + if emitted_plugins: + plugin_refs = [_extract_plugin_ref(plugin) for plugin in emitted_plugins] + return [ + PluginResponseSource(plugin=plugin, event_name=event_name, is_approximate=True) + for plugin in plugin_refs + if plugin is not None + ] + return [] + + +def _debug(ap: Any, message: str) -> None: + logger = getattr(ap, 'logger', None) + if logger is not None: + logger.debug(message) + + +def _build_delivery_failure_payload( + plugin_ref: dict[str, str], + event_name: str | None, + is_approximate: bool, + query: pipeline_query.Query, + response_index: int, + message_chain: platform_message.MessageChain, + error: Exception, +) -> dict[str, Any]: + details: dict[str, Any] = { + 'message_component_types': [component.__class__.__name__ for component in message_chain], + 'message_preview': str(message_chain)[:200], + } + if is_approximate: + details['attribution_warning'] = ( + 'This diagnostic was delivered to all plugins that handled the event because the ' + 'plugin runtime did not report the exact reply_message_chain source.' + ) + + return { + 'level': 'ERROR', + 'code': 'response_delivery_failed', + 'message': 'Failed to deliver a plugin-provided response message.', + 'plugin': plugin_ref, + 'query': { + 'query_id': query.query_id, + 'event_name': event_name or query.message_event.__class__.__name__, + 'stage': query.current_stage_name or 'SendResponseBackStage', + 'response_index': response_index, + }, + 'details': details, + 'delivery': { + 'error_type': error.__class__.__name__, + 'error_message': str(error), + 'traceback': traceback.format_exception_only(type(error), error)[-1].strip(), + }, + } diff --git a/src/langbot/pkg/pipeline/pool.py b/src/langbot/pkg/pipeline/pool.py new file mode 100644 index 0000000..55ce7fe --- /dev/null +++ b/src/langbot/pkg/pipeline/pool.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import asyncio +import typing + +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.provider.session as provider_session +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter + + +class QueryPool: + """请求池,请求获得调度进入pipeline之前,保存在这里""" + + query_id_counter: int = 0 + + pool_lock: asyncio.Lock + + queries: list[pipeline_query.Query] + + cached_queries: dict[int, pipeline_query.Query] + """Cached queries, used for plugin backward api call, will be removed after the query completely processed""" + + condition: asyncio.Condition + + def __init__(self): + self.query_id_counter = 0 + self.pool_lock = asyncio.Lock() + self.queries = [] + self.cached_queries = {} + self.condition = asyncio.Condition(self.pool_lock) + + async def add_query( + self, + bot_uuid: str, + launcher_type: provider_session.LauncherTypes, + launcher_id: typing.Union[int, str], + sender_id: typing.Union[int, str], + message_event: platform_events.MessageEvent, + message_chain: platform_message.MessageChain, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + pipeline_uuid: typing.Optional[str] = None, + routed_by_rule: bool = False, + variables: typing.Optional[dict[str, typing.Any]] = None, + ) -> pipeline_query.Query: + async with self.condition: + query_id = self.query_id_counter + initial_variables: dict[str, typing.Any] = {'_routed_by_rule': routed_by_rule} + if variables: + initial_variables.update(variables) + query = pipeline_query.Query( + bot_uuid=bot_uuid, + query_id=query_id, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_id, + message_event=message_event, + message_chain=message_chain, + variables=initial_variables, + resp_messages=[], + resp_message_chain=[], + adapter=adapter, + pipeline_uuid=pipeline_uuid, + ) + self.queries.append(query) + self.cached_queries[query_id] = query + self.query_id_counter += 1 + self.condition.notify_all() + return query + + async def __aenter__(self): + await self.pool_lock.acquire() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.pool_lock.release() diff --git a/src/langbot/pkg/pipeline/preproc/__init__.py b/src/langbot/pkg/pipeline/preproc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py new file mode 100644 index 0000000..b14d0a8 --- /dev/null +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import datetime + +from .. import stage, entities +from langbot_plugin.api.entities.builtin.provider import message as provider_message +import langbot_plugin.api.entities.events as events +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.platform.events as platform_events + + +@stage.stage_class('PreProcessor') +class PreProcessor(stage.PipelineStage): + """Request pre-processing stage + + Check out session, prompt, context, model, and content functions. + + Rewrite: + - session + - prompt + - messages + - user_message + - use_model + - use_funcs + """ + + @staticmethod + def _filter_selected_tools( + tools: list, + local_agent_config: dict, + ) -> list: + if local_agent_config.get('enable-all-tools', True) is not False: + return tools + + selected_tools = local_agent_config.get('tools', []) + if not isinstance(selected_tools, list): + return [] + + selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)} + return [tool for tool in tools if tool.name in selected_tool_names] + + async def process( + self, + query: pipeline_query.Query, + stage_inst_name: str, + ) -> entities.StageProcessResult: + """Process""" + selected_runner = query.pipeline_config['ai']['runner']['runner'] + local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) + include_skill_authoring = ( + selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None + ) + + session = await self.ap.sess_mgr.get_session(query) + + # When not local-agent, llm_model is None + llm_model = None + if selected_runner == 'local-agent': + # Read model config — new format is { primary: str, fallbacks: [str] }, + # but handle legacy plain string for backward compatibility + model_config = local_agent_config.get('model', {}) + if isinstance(model_config, str): + # Legacy format: plain UUID string + primary_uuid = model_config + fallback_uuids = [] + else: + primary_uuid = model_config.get('primary', '') + fallback_uuids = model_config.get('fallbacks', []) + + if primary_uuid: + try: + llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid) + except ValueError: + self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured') + + # Resolve fallback model UUIDs + if fallback_uuids: + valid_fallbacks = [] + for fb_uuid in fallback_uuids: + try: + await self.ap.model_mgr.get_model_by_uuid(fb_uuid) + valid_fallbacks.append(fb_uuid) + except ValueError: + self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping') + if valid_fallbacks: + query.variables['_fallback_model_uuids'] = valid_fallbacks + + conversation = await self.ap.sess_mgr.get_conversation( + query, + session, + query.pipeline_config['ai']['local-agent']['prompt'], + query.pipeline_uuid, + query.bot_uuid, + ) + + # Expire externally managed conversation ids after the conversation has + # been idle for longer than the configured conversation expire time. + # The idle window is measured from the last preprocess/update time, not + # from the conversation creation time. + conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None) + now = datetime.datetime.now() + if conversation_expire_time is not None and conversation_expire_time > 0: + last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None) + if last_update_time is not None: + conversation_idle_time = now.timestamp() - last_update_time.timestamp() + if conversation_idle_time > conversation_expire_time: + self.ap.logger.info( + f'Conversation({query.query_id}) is expired (idle: {conversation_idle_time}s), create new conversation' + ) + conversation.uuid = None + + # Treat every preprocess pass as a conversation activity update. This + # makes future expiry checks use the latest incoming message/preprocess + # time instead of the first message/creation time. + conversation.update_time = now + + # 设置query + query.session = session + query.prompt = conversation.prompt.copy() + query.messages = conversation.messages.copy() + + if selected_runner == 'local-agent': + query.use_funcs = [] + if llm_model: + query.use_llm_model_uuid = llm_model.model_entity.uuid + + if 'func_call' in (llm_model.model_entity.abilities or []): + # Get bound plugins and MCP servers for filtering tools + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) + include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) + all_tools = await self.ap.tool_mgr.get_all_tools( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=include_skill_authoring, + include_mcp_resource_tools=include_mcp_resource_tools, + ) + query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config) + + self.ap.logger.debug(f'Bound plugins: {bound_plugins}') + self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') + self.ap.logger.debug(f'Use funcs: {query.use_funcs}') + + # If primary model doesn't support func_call but fallback models exist, + # load tools anyway since fallback models may support them + if not query.use_funcs and query.variables.get('_fallback_model_uuids'): + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) + include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) + all_tools = await self.ap.tool_mgr.get_all_tools( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=include_skill_authoring, + include_mcp_resource_tools=include_mcp_resource_tools, + ) + query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config) + + sender_name = '' + + if isinstance(query.message_event, platform_events.GroupMessage): + sender_name = query.message_event.sender.member_name + elif isinstance(query.message_event, platform_events.FriendMessage): + sender_name = query.message_event.sender.nickname + + variables = { + 'launcher_type': query.session.launcher_type.value, + 'launcher_id': query.session.launcher_id, + 'sender_id': query.sender_id, + 'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}', + 'conversation_id': conversation.uuid, + 'msg_create_time': ( + int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp()) + ), + 'group_name': query.message_event.group.name + if isinstance(query.message_event, platform_events.GroupMessage) + else '', + 'sender_name': sender_name, + } + query.variables.update(variables) + + # Check if this model supports vision, if not, remove all images + # TODO this checking should be performed in runner, and in this stage, the image should be reserved + if selected_runner == 'local-agent' and llm_model and 'vision' not in (llm_model.model_entity.abilities or []): + for msg in query.messages: + if isinstance(msg.content, list): + for me in msg.content: + if me.type == 'image_url': + msg.content.remove(me) + + content_list: list[provider_message.ContentElement] = [] + + plain_text = '' + quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message') + + for me in query.message_chain: + if isinstance(me, platform_message.Plain): + content_list.append(provider_message.ContentElement.from_text(me.text)) + plain_text += me.text + elif isinstance(me, platform_message.Image): + if selected_runner != 'local-agent' or ( + llm_model and 'vision' in (llm_model.model_entity.abilities or []) + ): + if me.base64 is not None: + content_list.append(provider_message.ContentElement.from_image_base64(me.base64)) + elif isinstance(me, platform_message.Voice): + # 转成文件链接,让下游 runner 上传到目标模型 + if me.base64: + content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk')) + elif me.url: + content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice')) + elif isinstance(me, platform_message.File): + if me.base64: + content_list.append(provider_message.ContentElement.from_file_base64(me.base64, me.name)) + elif me.url: + content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name)) + elif isinstance(me, platform_message.Quote) and quote_msg: + for msg in me.origin: + if isinstance(msg, platform_message.Plain): + content_list.append(provider_message.ContentElement.from_text(msg.text)) + elif isinstance(msg, platform_message.Image): + if selected_runner != 'local-agent' or ( + llm_model and 'vision' in (llm_model.model_entity.abilities or []) + ): + if msg.base64 is not None: + content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) + elif isinstance(msg, platform_message.File): + if msg.base64: + content_list.append(provider_message.ContentElement.from_file_base64(msg.base64, msg.name)) + elif msg.url: + content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name)) + elif isinstance(msg, platform_message.Voice): + if msg.base64: + content_list.append( + provider_message.ContentElement.from_file_base64(msg.base64, 'voice.silk') + ) + elif msg.url: + content_list.append(provider_message.ContentElement.from_file_url(msg.url, 'voice')) + + query.variables['user_message_text'] = plain_text + + query.user_message = provider_message.Message(role='user', content=content_list) + + # Extract knowledge base UUIDs into query variables so plugins can modify them + # during PromptPreProcessing before the runner performs retrieval. + kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', []) + if not kb_uuids: + old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '') + if old_kb_uuid and old_kb_uuid != '__none__': + kb_uuids = [old_kb_uuid] + query.variables['_knowledge_base_uuids'] = list(kb_uuids) + + # =========== 触发事件 PromptPreProcessing + + event = events.PromptPreProcessing( + session_name=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + default_prompt=query.prompt.messages, + prompt=query.messages, + query=query, + ) + + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) + + query.prompt.messages = event_ctx.event.default_prompt + query.messages = event_ctx.event.prompt + + # =========== Skill awareness for the local-agent runner =========== + # The actual activation goes through the ``activate`` Tool Call so the + # LLM doesn't see full SKILL.md instructions until it commits to a + # skill (Claude Code's progressive disclosure). But the LLM still has + # to KNOW which skills exist to make that choice, so we: + # 1. resolve the pipeline's bound skills and stash them in + # ``query.variables['_pipeline_bound_skills']`` for downstream + # visibility checks (skill loader, native exec workdir); + # 2. inject a short ``Available Skills`` index (name + description + # only) into the system prompt. The contributor's original PR + # relied on this injection; without it the LLM never discovers + # the skills are there and just calls native tools instead. + if selected_runner == 'local-agent' and self.ap.skill_mgr: + pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid) + extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {}) + enable_all_skills = extensions_prefs.get('enable_all_skills', True) + + if enable_all_skills: + bound_skills = None # None = all loaded skills are visible + else: + bound_skills = extensions_prefs.get('skills', []) + + query.variables['_pipeline_bound_skills'] = bound_skills + + skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition( + bound_skills=bound_skills, + ) + if skill_addition: + # Append to the first system message; create one if the + # prompt has none. Handles both plain-string and + # content-element (list) message bodies. + if query.prompt.messages and query.prompt.messages[0].role == 'system': + head = query.prompt.messages[0] + if isinstance(head.content, str): + head.content = head.content + skill_addition + elif isinstance(head.content, list): + appended = False + for ce in head.content: + if getattr(ce, 'type', None) == 'text': + ce.text = (ce.text or '') + skill_addition + appended = True + break + if not appended: + head.content.append(provider_message.ContentElement(type='text', text=skill_addition)) + else: + query.prompt.messages.insert( + 0, + provider_message.Message(role='system', content=skill_addition.strip()), + ) + self.ap.logger.debug( + f'Skill index injected into system prompt: ' + f'pipeline={query.pipeline_uuid} ' + f'bound_skills={bound_skills or "all"} ' + f'loaded_skills={len(self.ap.skill_mgr.skills)}' + ) + else: + self.ap.logger.debug( + f'No skills available for prompt injection: ' + f'pipeline={query.pipeline_uuid} ' + f'loaded_skills={len(self.ap.skill_mgr.skills)} ' + f'bound_skills={bound_skills}' + ) + + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/process/__init__.py b/src/langbot/pkg/pipeline/process/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/process/handler.py b/src/langbot/pkg/pipeline/process/handler.py new file mode 100644 index 0000000..989cb0b --- /dev/null +++ b/src/langbot/pkg/pipeline/process/handler.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import abc + +from ...core import app +from .. import entities +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.provider.message as provider_message + + +class MessageHandler(metaclass=abc.ABCMeta): + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def initialize(self): + pass + + @abc.abstractmethod + async def handle( + self, + query: pipeline_query.Query, + ) -> entities.StageProcessResult: + raise NotImplementedError + + def cut_str(self, s: str) -> str: + """ + Take the first line of the string, up to 20 characters, if there are multiple lines, or more than 20 characters, add an ellipsis + """ + s0 = s.split('\n')[0] + if len(s0) > 20 or '\n' in s: + s0 = s0[:20] + '...' + return s0 + + def format_result_log( + self, + result: provider_message.Message | provider_message.MessageChunk, + ) -> str | None: + if result.tool_calls: + tool_names = [tc.function.name for tc in result.tool_calls if tc.function and tc.function.name] + if tool_names: + return f'{result.role}: requested tools: {", ".join(tool_names)}' + return f'{result.role}: requested tool calls' + + content = result.content + if isinstance(content, str): + if not content.strip(): + return None + + if result.role == 'tool': + if content.startswith('err:'): + return f'tool error: {self.cut_str(content)}' + + return self.cut_str(result.readable_str()) + + if isinstance(content, list) and len(content) == 0: + return None + + return self.cut_str(result.readable_str()) diff --git a/src/langbot/pkg/pipeline/process/handlers/__init__.py b/src/langbot/pkg/pipeline/process/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py new file mode 100644 index 0000000..4e5c14e --- /dev/null +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import uuid +import typing +import traceback +import time +from datetime import datetime + + +from .. import handler +from ... import entities +from ... import plugin_diagnostics +from ....provider import runner as runner_module + +import langbot_plugin.api.entities.events as events +from ....utils import importutil, constants, runner as runner_utils +from ....telemetry import features as telemetry_features +from ....provider import runners +import langbot_plugin.api.entities.builtin.provider.session as provider_session +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.provider.message as provider_message + + +importutil.import_modules_in_pkg(runners) + + +class ChatMessageHandler(handler.MessageHandler): + async def handle( + self, + query: pipeline_query.Query, + ) -> typing.AsyncGenerator[entities.StageProcessResult, None]: + """处理""" + # 调API + # 生成器 + + # 触发插件事件 + event_class = ( + events.PersonNormalMessageReceived + if query.launcher_type == provider_session.LauncherTypes.PERSON + else events.GroupNormalMessageReceived + ) + + event = event_class( + launcher_type=query.launcher_type.value, + launcher_id=query.launcher_id, + sender_id=query.sender_id, + text_message=str(query.message_chain), + message_event=query.message_event, + message_chain=query.message_chain, + query=query, + ) + + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) + + is_create_card = False # 判断下是否需要创建流式卡片 + + if event_ctx.is_prevented_default(): + if event_ctx.event.reply_message_chain is not None: + mc = event_ctx.event.reply_message_chain + plugin_diagnostics.record_pending_plugin_response_source( + query, + mc, + plugin_diagnostics.get_response_sources(event_ctx), + plugin_diagnostics.get_emitted_plugins(event_ctx), + event.event_name, + ) + query.resp_messages.append(mc) + + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + else: + self.ap.logger.debug( + f'NormalMessageReceived event prevented default for query {query.query_id} without reply' + ) + yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) + else: + if event_ctx.event.user_message_alter is not None: + if isinstance(event_ctx.event.user_message_alter, list): + query.user_message.content = event_ctx.event.user_message_alter + elif isinstance(event_ctx.event.user_message_alter, str): + query.user_message.content = [ + provider_message.ContentElement.from_text(event_ctx.event.user_message_alter) + ] + elif isinstance(event_ctx.event.user_message_alter, provider_message.ContentElement): + query.user_message.content = [event_ctx.event.user_message_alter] + + text_length = 0 + try: + is_stream = await query.adapter.is_stream_output_supported() + except AttributeError: + is_stream = False + + try: + for r in runner_module.preregistered_runners: + if r.name == query.pipeline_config['ai']['runner']['runner']: + runner = r(self.ap, query.pipeline_config) + break + else: + raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}') + # Mark start time for telemetry + start_ts = time.time() + + if is_stream: + resp_message_id = uuid.uuid4() + chunk_count = 0 # Track streaming chunks to reduce excessive logging + + async for result in runner.run(query): + result.resp_message_id = str(resp_message_id) + if query.resp_messages: + query.resp_messages.pop() + if query.resp_message_chain: + query.resp_message_chain.pop() + # 此时连接外部 AI 服务正常,创建卡片 + if not is_create_card: # 只有不是第一次才创建卡片 + await query.adapter.create_message_card(str(resp_message_id), query.message_event) + is_create_card = True + query.resp_messages.append(result) + + chunk_count += 1 + # Only log every 10th chunk to reduce excessive logging during streaming + # This prevents memory overflow from thousands of log entries per conversation + # First chunk uses INFO level to confirm connection establishment + if chunk_count == 1: + summary = self.format_result_log(result) + if summary is not None: + self.ap.logger.info(f'Conversation({query.query_id}) Streaming started: {summary}') + else: + self.ap.logger.info(f'Conversation({query.query_id}) Streaming started') + elif chunk_count % 10 == 0: + self.ap.logger.debug( + f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}' + ) + + if result.content is not None: + text_length += len(result.content) + + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + + # Log final summary after streaming completes + self.ap.logger.info( + f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars' + ) + + else: + async for result in runner.run(query): + query.resp_messages.append(result) + + summary = self.format_result_log(result) + if summary is not None: + self.ap.logger.info(f'Conversation({query.query_id}) Response: {summary}') + + if result.content is not None: + text_length += len(result.content) + + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + + query.session.using_conversation.messages.append(query.user_message) + + query.session.using_conversation.messages.extend(query.resp_messages) + except Exception as e: + error_info = f'{traceback.format_exc()}' + self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}') + traceback.print_exc() + + exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint') + + if exception_handling == 'show-error': + user_notice = f'{e}' + elif exception_handling == 'show-hint': + user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.') + else: # hide + user_notice = None + + yield entities.StageProcessResult( + result_type=entities.ResultType.INTERRUPT, + new_query=query, + user_notice=user_notice, + error_notice=f'{e}', + debug_notice=traceback.format_exc(), + ) + finally: + # Telemetry reporting: collect minimal per-query execution info and send asynchronously + try: + end_ts = time.time() + duration_ms = None + if 'start_ts' in locals(): + duration_ms = int((end_ts - start_ts) * 1000) + + adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None + runner_name = ( + query.pipeline_config.get('ai', {}).get('runner', {}).get('runner') + if query.pipeline_config + else None + ) + + # Model name if using localagent + model_name = None + try: + if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None): + m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid) + if m and getattr(m, 'model_entity', None): + model_name = getattr(m.model_entity, 'name', None) + except Exception: + model_name = None + + pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None) + + runner_category = runner_utils.get_runner_category_from_runner( + runner_name, runner, query.pipeline_config + ) + + # Feature usage collected during query processing (tool calls, + # knowledge base usage, sandbox executions, activated skills, ...) + features = telemetry_features.collect_features(query) + + payload = { + 'event_type': 'query', + 'query_id': query.query_id, + 'adapter': adapter_name, + 'runner': runner_name, + 'runner_category': runner_category, + 'duration_ms': duration_ms, + 'model_name': model_name, + 'version': constants.semantic_version, + 'instance_id': constants.instance_id, + 'edition': constants.edition, + 'pipeline_plugins': pipeline_plugins, + 'features': features, + 'error': locals().get('error_info', None), + 'timestamp': datetime.utcnow().isoformat(), + } + + # Send telemetry asynchronously and do not block pipeline via app's telemetry manager + await self.ap.telemetry.start_send_task(payload) + + # Trigger survey events on successful non-WebSocket responses + if not locals().get('error_info') and adapter_name and 'WebSocket' not in adapter_name: + if self.ap.survey: + await self.ap.survey.trigger_event('first_bot_response_success') + # Counts toward the bot_response_success_100 milestone event + await self.ap.survey.record_bot_response_success() + except Exception as ex: + # Ensure telemetry issues do not affect normal flow + self.ap.logger.warning(f'Failed to send telemetry: {ex}') diff --git a/src/langbot/pkg/pipeline/process/handlers/command.py b/src/langbot/pkg/pipeline/process/handlers/command.py new file mode 100644 index 0000000..09fa537 --- /dev/null +++ b/src/langbot/pkg/pipeline/process/handlers/command.py @@ -0,0 +1,118 @@ +from __future__ import annotations +import typing + + +from .. import handler +from ... import entities +from ... import plugin_diagnostics +import langbot_plugin.api.entities.builtin.provider.message as provider_message +import langbot_plugin.api.entities.builtin.provider.session as provider_session +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.events as events + + +class CommandHandler(handler.MessageHandler): + async def handle( + self, + query: pipeline_query.Query, + ) -> typing.AsyncGenerator[entities.StageProcessResult, None]: + """Process""" + + full_command_text = str(query.message_chain).strip() + + command_text = full_command_text[1:] + + privilege = 1 + + if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']: + privilege = 2 + + spt = command_text.split(' ') + + event_class = ( + events.PersonCommandSent + if query.launcher_type == provider_session.LauncherTypes.PERSON + else events.GroupCommandSent + ) + + event = event_class( + launcher_type=query.launcher_type.value, + launcher_id=query.launcher_id, + sender_id=query.sender_id, + command=spt[0], + params=spt[1:] if len(spt) > 1 else [], + text_message=full_command_text, + is_admin=(privilege == 2), + query=query, + ) + + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) + + if event_ctx.is_prevented_default(): + if event_ctx.event.reply_message_chain is not None: + mc = event_ctx.event.reply_message_chain + plugin_diagnostics.record_pending_plugin_response_source( + query, + mc, + plugin_diagnostics.get_response_sources(event_ctx), + plugin_diagnostics.get_emitted_plugins(event_ctx), + event.event_name, + ) + + query.resp_messages.append(mc) + + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + else: + yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) + + else: + session = await self.ap.sess_mgr.get_session(query) + + async for ret in self.ap.cmd_mgr.execute( + command_text=command_text, full_command_text=full_command_text, query=query, session=session + ): + if ret.error is not None: + query.resp_messages.append( + provider_message.Message( + role='command', + content=str(ret.error), + ) + ) + + self.ap.logger.info(f'Command({query.query_id}) error: {self.cut_str(str(ret.error))}') + + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + elif ( + ret.text is not None + or ret.image_url is not None + or ret.image_base64 is not None + or ret.file_url is not None + ): + content: list[provider_message.ContentElement] = [] + + if ret.text is not None: + content.append(provider_message.ContentElement.from_text(ret.text)) + + if ret.image_url is not None: + content.append(provider_message.ContentElement.from_image_url(ret.image_url)) + + if ret.image_base64 is not None: + content.append(provider_message.ContentElement.from_image_base64(ret.image_base64)) + + if ret.file_url is not None: + # 此时为 file 类型 + content.append(provider_message.ContentElement.from_file_url(ret.file_url, ret.file_name)) + query.resp_messages.append( + provider_message.Message( + role='command', + content=content, + ) + ) + + self.ap.logger.info(f'Command returned: {self.cut_str(str(content[0]))}') + + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + else: + yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) diff --git a/src/langbot/pkg/pipeline/process/process.py b/src/langbot/pkg/pipeline/process/process.py new file mode 100644 index 0000000..27632f2 --- /dev/null +++ b/src/langbot/pkg/pipeline/process/process.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from . import handler +from .handlers import chat, command +from .. import entities +from .. import stage +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@stage.stage_class('MessageProcessor') +class Processor(stage.PipelineStage): + """请求实际处理阶段 + + 通过命令处理器和聊天处理器处理消息。 + + 改写: + - resp_messages + """ + + cmd_handler: handler.MessageHandler + + chat_handler: handler.MessageHandler + + async def initialize(self, pipeline_config: dict): + self.cmd_handler = command.CommandHandler(self.ap) + self.chat_handler = chat.ChatMessageHandler(self.ap) + + await self.cmd_handler.initialize() + await self.chat_handler.initialize() + + async def process( + self, + query: pipeline_query.Query, + stage_inst_name: str, + ) -> entities.StageProcessResult: + """Process""" + message_text = str(query.message_chain).strip() + + self.ap.logger.info( + f'Processing request from {query.launcher_type.value}_{query.launcher_id} ({query.query_id}): {message_text}' + ) + + async def generator(): + cmd_prefix = self.ap.instance_config.data['command']['prefix'] + cmd_enable = self.ap.instance_config.data['command'].get('enable', True) + + if cmd_enable and any(message_text.startswith(prefix) for prefix in cmd_prefix): + handler_to_use = self.cmd_handler + else: + handler_to_use = self.chat_handler + + async for result in handler_to_use.handle(query): + yield result + + return generator() diff --git a/src/langbot/pkg/pipeline/ratelimit/__init__.py b/src/langbot/pkg/pipeline/ratelimit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/ratelimit/algo.py b/src/langbot/pkg/pipeline/ratelimit/algo.py new file mode 100644 index 0000000..efbc326 --- /dev/null +++ b/src/langbot/pkg/pipeline/ratelimit/algo.py @@ -0,0 +1,68 @@ +from __future__ import annotations +import abc +import typing + +from ...core import app +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +preregistered_algos: list[typing.Type[ReteLimitAlgo]] = [] + + +def algo_class(name: str): + def decorator(cls: typing.Type[ReteLimitAlgo]) -> typing.Type[ReteLimitAlgo]: + cls.name = name + preregistered_algos.append(cls) + return cls + + return decorator + + +class ReteLimitAlgo(metaclass=abc.ABCMeta): + """限流算法抽象类""" + + name: str = None + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def initialize(self): + pass + + @abc.abstractmethod + async def require_access( + self, + query: pipeline_query.Query, + launcher_type: str, + launcher_id: typing.Union[int, str], + ) -> bool: + """进入处理流程 + + 这个方法对等待是友好的,意味着算法可以实现在这里等待一段时间以控制速率。 + + Args: + launcher_type (str): 请求者类型 群聊为 group 私聊为 person + launcher_id (int): 请求者ID + + Returns: + bool: 是否允许进入处理流程,若返回false,则直接丢弃该请求 + """ + raise NotImplementedError + + @abc.abstractmethod + async def release_access( + self, + query: pipeline_query.Query, + launcher_type: str, + launcher_id: typing.Union[int, str], + ): + """退出处理流程 + + Args: + launcher_type (str): 请求者类型 群聊为 group 私聊为 person + launcher_id (int): 请求者ID + """ + + raise NotImplementedError diff --git a/src/langbot/pkg/pipeline/ratelimit/algos/__init__.py b/src/langbot/pkg/pipeline/ratelimit/algos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py b/src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py new file mode 100644 index 0000000..6a2a8e9 --- /dev/null +++ b/src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py @@ -0,0 +1,98 @@ +from __future__ import annotations +import asyncio +import time +import typing +from .. import algo +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +# 固定窗口算法 +class SessionContainer: + wait_lock: asyncio.Lock + + records: dict[int, int] + """访问记录,key为每窗口长度的起始时间戳,value为访问次数""" + + def __init__(self): + self.wait_lock = asyncio.Lock() + self.records = {} + + +@algo.algo_class('fixwin') +class FixedWindowAlgo(algo.ReteLimitAlgo): + containers_lock: asyncio.Lock + """访问记录容器锁""" + + containers: dict[str, SessionContainer] + """访问记录容器,key为launcher_type launcher_id""" + + async def initialize(self): + self.containers_lock = asyncio.Lock() + self.containers = {} + + async def require_access( + self, + query: pipeline_query.Query, + launcher_type: str, + launcher_id: typing.Union[int, str], + ) -> bool: + # 加锁,找容器 + container: SessionContainer = None + + session_name = f'{launcher_type}_{launcher_id}' + + async with self.containers_lock: + container = self.containers.get(session_name) + + if container is None: + container = SessionContainer() + self.containers[session_name] = container + + # 等待锁 + async with container.wait_lock: + # 获取窗口大小和限制 + window_size = query.pipeline_config['safety']['rate-limit']['window-length'] + limitation = query.pipeline_config['safety']['rate-limit']['limitation'] + + # TODO revert it + # if session_name in self.ap.pipeline_cfg.data['rate-limit']['fixwin']: + # window_size = self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name]['window-size'] + # limitation = self.ap.pipeline_cfg.data['rate-limit']['fixwin'][session_name]['limit'] + + # 获取当前时间戳 + now = int(time.time()) + + # 获取当前窗口的起始时间戳 + now = now - now % window_size + + # 获取当前窗口的访问次数 + count = container.records.get(now, 0) + + # 如果访问次数超过了限制 + if count >= limitation: + if query.pipeline_config['safety']['rate-limit']['strategy'] == 'drop': + return False + elif query.pipeline_config['safety']['rate-limit']['strategy'] == 'wait': + # 等待下一窗口 + await asyncio.sleep(window_size - time.time() % window_size) + + now = int(time.time()) + now = now - now % window_size + + if now not in container.records: + container.records = {} + container.records[now] = 1 + else: + # 访问次数加一 + container.records[now] = count + 1 + + # 返回True + return True + + async def release_access( + self, + query: pipeline_query.Query, + launcher_type: str, + launcher_id: typing.Union[int, str], + ): + pass diff --git a/src/langbot/pkg/pipeline/ratelimit/ratelimit.py b/src/langbot/pkg/pipeline/ratelimit/ratelimit.py new file mode 100644 index 0000000..cab62b8 --- /dev/null +++ b/src/langbot/pkg/pipeline/ratelimit/ratelimit.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import typing + +from .. import entities, stage +from . import algo +from ...utils import importutil + +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + +from . import algos + +importutil.import_modules_in_pkg(algos) + + +@stage.stage_class('RequireRateLimitOccupancy') +@stage.stage_class('ReleaseRateLimitOccupancy') +class RateLimit(stage.PipelineStage): + """限速器控制阶段 + + 不改写query,只检查是否需要限速。 + """ + + algo: algo.ReteLimitAlgo + + async def initialize(self, pipeline_config: dict): + algo_name = 'fixwin' + + algo_class = None + + for algo_cls in algo.preregistered_algos: + if algo_cls.name == algo_name: + algo_class = algo_cls + break + else: + raise ValueError(f'未知的限速算法: {algo_name}') + + self.algo = algo_class(self.ap) + await self.algo.initialize() + + async def process( + self, + query: pipeline_query.Query, + stage_inst_name: str, + ) -> typing.Union[ + entities.StageProcessResult, + typing.AsyncGenerator[entities.StageProcessResult, None], + ]: + """处理""" + if stage_inst_name == 'RequireRateLimitOccupancy': + if await self.algo.require_access( + query, + query.launcher_type.value, + query.launcher_id, + ): + return entities.StageProcessResult( + result_type=entities.ResultType.CONTINUE, + new_query=query, + ) + else: + return entities.StageProcessResult( + result_type=entities.ResultType.INTERRUPT, + new_query=query, + console_notice=f'根据限速规则忽略 {query.launcher_type.value}:{query.launcher_id} 消息', + user_notice='请求数超过限速器设定值,已丢弃本消息。', + ) + elif stage_inst_name == 'ReleaseRateLimitOccupancy': + await self.algo.release_access( + query, + query.launcher_type.value, + query.launcher_id, + ) + return entities.StageProcessResult( + result_type=entities.ResultType.CONTINUE, + new_query=query, + ) diff --git a/src/langbot/pkg/pipeline/respback/__init__.py b/src/langbot/pkg/pipeline/respback/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/respback/respback.py b/src/langbot/pkg/pipeline/respback/respback.py new file mode 100644 index 0000000..6d8248a --- /dev/null +++ b/src/langbot/pkg/pipeline/respback/respback.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import random +import asyncio + + +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.provider.message as provider_message + +from .. import stage, entities +from .. import plugin_diagnostics +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@stage.stage_class('SendResponseBackStage') +class SendResponseBackStage(stage.PipelineStage): + """发送响应消息""" + + async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: + """处理""" + + random_range = ( + query.pipeline_config['output']['force-delay']['min'], + query.pipeline_config['output']['force-delay']['max'], + ) + + random_delay = random.uniform(*random_range) + + self.ap.logger.debug('根据规则强制延迟回复: %s s', random_delay) + + await asyncio.sleep(random_delay) + + if query.pipeline_config['output']['misc']['at-sender'] and isinstance( + query.message_event, platform_events.GroupMessage + ): + query.resp_message_chain[-1].insert(0, platform_message.At(target=query.message_event.sender.id)) + + quote_origin = query.pipeline_config['output']['misc']['quote-origin'] + + has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages) + # TODO 命令与流式的兼容性问题 + response_index = len(query.resp_message_chain) - 1 + message_chain = query.resp_message_chain[-1] + + try: + if await query.adapter.is_stream_output_supported() and has_chunks: + is_final = [msg.is_final for msg in query.resp_messages][-1] + await query.adapter.reply_message_chunk( + message_source=query.message_event, + bot_message=query.resp_messages[-1], + message=message_chain, + quote_origin=quote_origin, + is_final=is_final, + ) + else: + await query.adapter.reply_message( + message_source=query.message_event, + message=message_chain, + quote_origin=quote_origin, + ) + except Exception as e: + await plugin_diagnostics.notify_response_delivery_failure( + self.ap, + query, + response_index, + message_chain, + e, + ) + plugin_diagnostics.clear_response_source(query, response_index) + raise + plugin_diagnostics.clear_response_source(query, response_index) + + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/resprule/__init__.py b/src/langbot/pkg/pipeline/resprule/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/resprule/entities.py b/src/langbot/pkg/pipeline/resprule/entities.py new file mode 100644 index 0000000..71973c8 --- /dev/null +++ b/src/langbot/pkg/pipeline/resprule/entities.py @@ -0,0 +1,9 @@ +import pydantic + +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +class RuleJudgeResult(pydantic.BaseModel): + matching: bool = False + + replacement: platform_message.MessageChain = None diff --git a/src/langbot/pkg/pipeline/resprule/resprule.py b/src/langbot/pkg/pipeline/resprule/resprule.py new file mode 100644 index 0000000..6e7fc36 --- /dev/null +++ b/src/langbot/pkg/pipeline/resprule/resprule.py @@ -0,0 +1,62 @@ +from __future__ import annotations + + +from . import rule + +from .. import stage, entities +from ...utils import importutil + +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + +from . import rules + +importutil.import_modules_in_pkg(rules) + + +@stage.stage_class('GroupRespondRuleCheckStage') +class GroupRespondRuleCheckStage(stage.PipelineStage): + """群组响应规则检查器 + + 仅检查群消息是否符合规则。 + """ + + rule_matchers: list[rule.GroupRespondRule] + """检查器实例""" + + async def initialize(self, pipeline_config: dict): + """初始化检查器""" + + self.rule_matchers = [] + + for rule_matcher in rule.preregisetered_rules: + rule_inst = rule_matcher(self.ap) + await rule_inst.initialize() + self.rule_matchers.append(rule_inst) + + async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> entities.StageProcessResult: + if query.launcher_type.value != 'group': # 只处理群消息 + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + + # 通过路由规则明确指定的流水线,跳过群响应规则检查 + if query.variables and query.variables.get('_routed_by_rule', False): + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + + rules = query.pipeline_config['trigger']['group-respond-rules'] + + use_rule = rules + + # TODO revert it + # if str(query.launcher_id) in rules: + # use_rule = rules[str(query.launcher_id)] + + for rule_matcher in self.rule_matchers: # 任意一个匹配就放行 + res = await rule_matcher.match(str(query.message_chain), query.message_chain, use_rule, query) + if res.matching: + query.message_chain = res.replacement + + return entities.StageProcessResult( + result_type=entities.ResultType.CONTINUE, + new_query=query, + ) + + return entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) diff --git a/src/langbot/pkg/pipeline/resprule/rule.py b/src/langbot/pkg/pipeline/resprule/rule.py new file mode 100644 index 0000000..34e89a7 --- /dev/null +++ b/src/langbot/pkg/pipeline/resprule/rule.py @@ -0,0 +1,46 @@ +from __future__ import annotations +import abc +import typing + +from ...core import app +from . import entities + +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +preregisetered_rules: list[typing.Type[GroupRespondRule]] = [] + + +def rule_class(name: str): + def decorator(cls: typing.Type[GroupRespondRule]) -> typing.Type[GroupRespondRule]: + cls.name = name + preregisetered_rules.append(cls) + return cls + + return decorator + + +class GroupRespondRule(metaclass=abc.ABCMeta): + """群组响应规则的抽象类""" + + name: str + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def initialize(self): + pass + + @abc.abstractmethod + async def match( + self, + message_text: str, + message_chain: platform_message.MessageChain, + rule_dict: dict, + query: pipeline_query.Query, + ) -> entities.RuleJudgeResult: + """判断消息是否匹配规则""" + raise NotImplementedError diff --git a/src/langbot/pkg/pipeline/resprule/rules/__init__.py b/src/langbot/pkg/pipeline/resprule/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/resprule/rules/atbot.py b/src/langbot/pkg/pipeline/resprule/rules/atbot.py new file mode 100644 index 0000000..86a5800 --- /dev/null +++ b/src/langbot/pkg/pipeline/resprule/rules/atbot.py @@ -0,0 +1,38 @@ +from __future__ import annotations + + +from .. import rule as rule_model +from .. import entities +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@rule_model.rule_class('at-bot') +class AtBotRule(rule_model.GroupRespondRule): + async def match( + self, + message_text: str, + message_chain: platform_message.MessageChain, + rule_dict: dict, + query: pipeline_query.Query, + ) -> entities.RuleJudgeResult: + found = False + + def remove_at(message_chain: platform_message.MessageChain): + nonlocal found + for component in message_chain.root: + if isinstance(component, platform_message.At) and str(component.target) == str( + query.adapter.bot_account_id + ): + message_chain.remove(component) + found = True + break + + remove_at(message_chain) + remove_at(message_chain) # 回复消息时会at两次,检查并删除重复的 + + should_respond_at = rule_dict.get('at', None) + if should_respond_at is not None: + return entities.RuleJudgeResult(matching=found and bool(should_respond_at), replacement=message_chain) + + return entities.RuleJudgeResult(matching=found, replacement=message_chain) diff --git a/src/langbot/pkg/pipeline/resprule/rules/prefix.py b/src/langbot/pkg/pipeline/resprule/rules/prefix.py new file mode 100644 index 0000000..72f0de7 --- /dev/null +++ b/src/langbot/pkg/pipeline/resprule/rules/prefix.py @@ -0,0 +1,30 @@ +from .. import rule as rule_model +from .. import entities +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@rule_model.rule_class('prefix') +class PrefixRule(rule_model.GroupRespondRule): + async def match( + self, + message_text: str, + message_chain: platform_message.MessageChain, + rule_dict: dict, + query: pipeline_query.Query, + ) -> entities.RuleJudgeResult: + prefixes = rule_dict['prefix'] + + for prefix in prefixes: + if message_text.startswith(prefix): + # 查找第一个plain元素 + for me in message_chain: + if isinstance(me, platform_message.Plain): + me.text = me.text[len(prefix) :] + + return entities.RuleJudgeResult( + matching=True, + replacement=message_chain, + ) + + return entities.RuleJudgeResult(matching=False, replacement=message_chain) diff --git a/src/langbot/pkg/pipeline/resprule/rules/random.py b/src/langbot/pkg/pipeline/resprule/rules/random.py new file mode 100644 index 0000000..2bfe8b7 --- /dev/null +++ b/src/langbot/pkg/pipeline/resprule/rules/random.py @@ -0,0 +1,21 @@ +import random + + +from .. import rule as rule_model +from .. import entities +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@rule_model.rule_class('random') +class RandomRespRule(rule_model.GroupRespondRule): + async def match( + self, + message_text: str, + message_chain: platform_message.MessageChain, + rule_dict: dict, + query: pipeline_query.Query, + ) -> entities.RuleJudgeResult: + random_rate = rule_dict['random'] + + return entities.RuleJudgeResult(matching=random.random() < random_rate, replacement=message_chain) diff --git a/src/langbot/pkg/pipeline/resprule/rules/regexp.py b/src/langbot/pkg/pipeline/resprule/rules/regexp.py new file mode 100644 index 0000000..41e1df8 --- /dev/null +++ b/src/langbot/pkg/pipeline/resprule/rules/regexp.py @@ -0,0 +1,30 @@ +import re + + +from .. import rule as rule_model +from .. import entities +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@rule_model.rule_class('regexp') +class RegExpRule(rule_model.GroupRespondRule): + async def match( + self, + message_text: str, + message_chain: platform_message.MessageChain, + rule_dict: dict, + query: pipeline_query.Query, + ) -> entities.RuleJudgeResult: + regexps = rule_dict['regexp'] + + for regexp in regexps: + match = re.match(regexp, message_text) + + if match: + return entities.RuleJudgeResult( + matching=True, + replacement=message_chain, + ) + + return entities.RuleJudgeResult(matching=False, replacement=message_chain) diff --git a/src/langbot/pkg/pipeline/stage.py b/src/langbot/pkg/pipeline/stage.py new file mode 100644 index 0000000..0ff1af7 --- /dev/null +++ b/src/langbot/pkg/pipeline/stage.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import abc +import typing + +from ..core import app +from . import entities +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +preregistered_stages: dict[str, type[PipelineStage]] = {} + + +def stage_class(name: str) -> typing.Callable[[type[PipelineStage]], type[PipelineStage]]: + def decorator(cls: type[PipelineStage]) -> type[PipelineStage]: + preregistered_stages[name] = cls + return cls + + return decorator + + +class PipelineStage(metaclass=abc.ABCMeta): + """流水线阶段""" + + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def initialize(self, pipeline_config: dict): + """初始化""" + pass + + @abc.abstractmethod + async def process( + self, + query: pipeline_query.Query, + stage_inst_name: str, + ) -> typing.Union[ + entities.StageProcessResult, + typing.AsyncGenerator[entities.StageProcessResult, None], + ]: + """处理""" + raise NotImplementedError diff --git a/src/langbot/pkg/pipeline/wrapper/__init__.py b/src/langbot/pkg/pipeline/wrapper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/pipeline/wrapper/wrapper.py b/src/langbot/pkg/pipeline/wrapper/wrapper.py new file mode 100644 index 0000000..50db693 --- /dev/null +++ b/src/langbot/pkg/pipeline/wrapper/wrapper.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import typing + +from .. import entities +from .. import plugin_diagnostics +from .. import stage + +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.provider.message as provider_message +import langbot_plugin.api.entities.events as events + + +@stage.stage_class('ResponseWrapper') +class ResponseWrapper(stage.PipelineStage): + """回复包装阶段 + + 把回复的 message 包装成人类识读的形式。 + + 改写: + - resp_message_chain + """ + + async def initialize(self, pipeline_config: dict): + pass + + def _is_final_assistant_message(self, result) -> bool: + """Whether *result* is the agent's final, tool-call-free answer. + + Intermediate streaming chunks and tool-call rounds must NOT trigger + outbound attachment collection — only the terminal assistant message. + """ + if getattr(result, 'role', None) != 'assistant': + return False + if result.tool_calls: + return False + if isinstance(result, provider_message.MessageChunk): + return bool(result.is_final) + return True + + async def _append_outbound_attachments( + self, + query: pipeline_query.Query, + message_chain: platform_message.MessageChain, + ) -> None: + """Collect sandbox outbox files and append them to *message_chain*. + + Runs at most once per query (guarded by a query variable) and never + raises into the pipeline — attachment delivery is best-effort. + """ + if query.variables.get('_sandbox_outbound_collected'): + return + box_service = getattr(self.ap, 'box_service', None) + if box_service is None or not getattr(box_service, 'available', False): + return + query.variables['_sandbox_outbound_collected'] = True + try: + attachments = await box_service.collect_outbound_attachments(query) + except Exception as e: + self.ap.logger.warning(f'Outbound attachment collection failed: {e}') + return + for att in attachments: + att_type = att.get('type') + if att_type == 'Image': + message_chain.append(platform_message.Image(base64=att['base64'])) + elif att_type == 'Voice': + message_chain.append(platform_message.Voice(base64=att['base64'])) + else: + message_chain.append(platform_message.File(name=att.get('name', 'file'), base64=att['base64'])) + + async def process( + self, + query: pipeline_query.Query, + stage_inst_name: str, + ) -> typing.AsyncGenerator[entities.StageProcessResult, None]: + """处理""" + + # 如果 resp_messages[-1] 已经是 MessageChain 了 + if isinstance(query.resp_messages[-1], platform_message.MessageChain): + query.resp_message_chain.append(query.resp_messages[-1]) + plugin_diagnostics.consume_pending_plugin_response_source( + query, + query.resp_messages[-1], + len(query.resp_message_chain) - 1, + ) + + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + + else: + if query.resp_messages[-1].role == 'command': + query.resp_message_chain.append( + query.resp_messages[-1].get_content_platform_message_chain(prefix_text='[bot] ') + ) + + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + elif query.resp_messages[-1].role == 'plugin': + query.resp_message_chain.append(query.resp_messages[-1].get_content_platform_message_chain()) + + yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + else: + if query.resp_messages[-1].role == 'assistant': + result = query.resp_messages[-1] + session = await self.ap.sess_mgr.get_session(query) + + reply_text = '' + + if result.content: # 有内容 + reply_text = str(result.get_content_platform_message_chain()) + + # ============= 触发插件事件 =============== + event = events.NormalMessageResponded( + launcher_type=query.launcher_type.value, + launcher_id=query.launcher_id, + sender_id=query.sender_id, + session=session, + prefix='', + response_text=reply_text, + finish_reason='stop', + funcs_called=[fc.function.name for fc in result.tool_calls] + if result.tool_calls is not None + else [], + query=query, + ) + + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) + + if event_ctx.is_prevented_default(): + yield entities.StageProcessResult( + result_type=entities.ResultType.INTERRUPT, + new_query=query, + ) + else: + if event_ctx.event.reply_message_chain is not None: + reply_chain = event_ctx.event.reply_message_chain + is_plugin_reply = True + else: + reply_chain = result.get_content_platform_message_chain() + is_plugin_reply = False + + # Attach files the agent produced in the sandbox + # outbox, but only on the terminal assistant message. + if self._is_final_assistant_message(result): + await self._append_outbound_attachments(query, reply_chain) + + query.resp_message_chain.append(reply_chain) + if is_plugin_reply: + plugin_diagnostics.record_last_plugin_response_source( + query, + plugin_diagnostics.get_response_sources(event_ctx), + plugin_diagnostics.get_emitted_plugins(event_ctx), + event.event_name, + ) + + yield entities.StageProcessResult( + result_type=entities.ResultType.CONTINUE, + new_query=query, + ) + + if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用 + function_names = [tc.function.name for tc in result.tool_calls] + + reply_text = f'Call {".".join(function_names)}...' + + query.resp_message_chain.append( + platform_message.MessageChain([platform_message.Plain(text=reply_text)]) + ) + + if query.pipeline_config['output']['misc']['track-function-calls']: + event = events.NormalMessageResponded( + launcher_type=query.launcher_type.value, + launcher_id=query.launcher_id, + sender_id=query.sender_id, + session=session, + prefix='', + response_text=reply_text, + finish_reason='stop', + funcs_called=[fc.function.name for fc in result.tool_calls] + if result.tool_calls is not None + else [], + query=query, + ) + + # Get bound plugins for filtering + bound_plugins = query.variables.get('_pipeline_bound_plugins', None) + event_ctx = await self.ap.plugin_connector.emit_event(event, bound_plugins) + + if event_ctx.is_prevented_default(): + yield entities.StageProcessResult( + result_type=entities.ResultType.INTERRUPT, + new_query=query, + ) + else: + if event_ctx.event.reply_message_chain is not None: + query.resp_message_chain.append(event_ctx.event.reply_message_chain) + plugin_diagnostics.record_last_plugin_response_source( + query, + plugin_diagnostics.get_response_sources(event_ctx), + plugin_diagnostics.get_emitted_plugins(event_ctx), + event.event_name, + ) + + else: + query.resp_message_chain.append( + platform_message.MessageChain([platform_message.Plain(text=reply_text)]) + ) + + yield entities.StageProcessResult( + result_type=entities.ResultType.CONTINUE, + new_query=query, + ) diff --git a/src/langbot/pkg/platform/__init__.py b/src/langbot/pkg/platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py new file mode 100644 index 0000000..6e99520 --- /dev/null +++ b/src/langbot/pkg/platform/botmgr.py @@ -0,0 +1,564 @@ +from __future__ import annotations + +import asyncio +import json +import re +import traceback +import sqlalchemy + +from ..core import app, entities as core_entities, taskmgr + +from ..discover import engine + +from ..entity.persistence import bot as persistence_bot +from ..entity.persistence import pipeline as persistence_pipeline + +from ..entity.errors import platform as platform_errors + +from .logger import EventLogger + +import langbot_plugin.api.entities.builtin.provider.session as provider_session +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter + + +class RuntimeBot: + """运行时机器人""" + + ap: app.Application + + bot_entity: persistence_bot.Bot + + enable: bool + + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter + + task_wrapper: taskmgr.TaskWrapper + + task_context: taskmgr.TaskContext + + logger: EventLogger + + def __init__( + self, + ap: app.Application, + bot_entity: persistence_bot.Bot, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + logger: EventLogger, + ): + self.ap = ap + self.bot_entity = bot_entity + self.enable = bot_entity.enable + self.adapter = adapter + self.task_context = taskmgr.TaskContext() + self.logger = logger + + @staticmethod + def _match_operator(actual: str, operator: str, expected: str) -> bool: + """Evaluate a single operator condition.""" + if operator == 'eq': + return actual == expected + elif operator == 'neq': + return actual != expected + elif operator == 'contains': + return expected in actual + elif operator == 'not_contains': + return expected not in actual + elif operator == 'starts_with': + return actual.startswith(expected) + elif operator == 'regex': + try: + return bool(re.search(expected, actual)) + except re.error: + return False + return False + + PIPELINE_DISCARD = '__discard__' + PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded' + + def resolve_pipeline_uuid( + self, + launcher_type: str, + launcher_id: str, + message_text: str, + message_element_types: list[str] | None = None, + ) -> tuple[str | None, bool]: + """Resolve pipeline UUID based on routing rules. + + Rules are evaluated in order; first match wins. + Falls back to use_pipeline_uuid if no rule matches. + + Rule types: + - launcher_type: session type ("person" / "group") + - launcher_id: session / group id + - message_content: message text content + - message_has_element: message contains element of given type + (Image, Voice, File, Forward, Face, At, AtAll, Quote) + Operators: eq (has), neq (doesn't have) + + Operators: eq, neq, contains, not_contains, starts_with, regex + + When pipeline_uuid is ``__discard__``, the message should be + silently dropped by the caller. + + Returns: + tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True + when a routing rule matched, False when falling back to default. + """ + rules = self.bot_entity.pipeline_routing_rules or [] + element_type_set = set(message_element_types or []) + + for rule in rules: + rule_type = rule.get('type') + operator = rule.get('operator', 'eq') + rule_value = rule.get('value', '') + target_uuid = rule.get('pipeline_uuid') + if not rule_type or not target_uuid: + continue + + if rule_type == 'launcher_type': + if self._match_operator(launcher_type, operator, rule_value): + return target_uuid, True + elif rule_type == 'launcher_id': + if self._match_operator(str(launcher_id), operator, str(rule_value)): + return target_uuid, True + elif rule_type == 'message_content': + if self._match_operator(message_text, operator, rule_value): + return target_uuid, True + elif rule_type == 'message_has_element': + has_element = rule_value in element_type_set + if operator == 'eq' and has_element: + return target_uuid, True + elif operator == 'neq' and not has_element: + return target_uuid, True + + return self.bot_entity.use_pipeline_uuid, False + + async def _record_discarded_message( + self, + launcher_type: provider_session.LauncherTypes, + launcher_id: str | int, + sender_id: str | int, + message_event: platform_events.MessageEvent, + message_chain: platform_message.MessageChain, + ) -> None: + """Record a discarded message in the monitoring system.""" + try: + if hasattr(message_chain, 'model_dump'): + message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False) + else: + message_content = str(message_chain) + + sender_name = None + if hasattr(message_event, 'sender'): + if hasattr(message_event.sender, 'nickname'): + sender_name = message_event.sender.nickname + elif hasattr(message_event.sender, 'member_name'): + sender_name = message_event.sender.member_name + + # Use the same session_id format as monitoring_helper.py + session_id = f'{launcher_type}_{launcher_id}' + platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type) + + await self.ap.monitoring_service.record_message( + bot_id=self.bot_entity.uuid, + bot_name=self.bot_entity.name or self.bot_entity.uuid, + pipeline_id=self.PIPELINE_DISCARD, + pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME, + message_content=message_content, + session_id=session_id, + status='discarded', + level='info', + platform=platform, + user_id=str(sender_id), + user_name=sender_name, + ) + + # Ensure the session exists so the message appears in the session monitor. + # Don't overwrite pipeline info — a session may have messages from + # multiple pipelines; discarding shouldn't change the displayed pipeline. + session_updated = await self.ap.monitoring_service.update_session_activity( + session_id, + ) + if not session_updated: + # No session yet (first message for this launcher was discarded). + await self.ap.monitoring_service.record_session_start( + session_id=session_id, + bot_id=self.bot_entity.uuid, + bot_name=self.bot_entity.name or self.bot_entity.uuid, + pipeline_id=self.PIPELINE_DISCARD, + pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME, + platform=platform, + user_id=str(sender_id), + user_name=sender_name, + ) + except Exception as e: + await self.logger.error(f'Failed to record discarded message: {e}') + + async def initialize(self): + async def on_friend_message( + event: platform_events.FriendMessage, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ): + image_components = [ + component for component in event.message_chain if isinstance(component, platform_message.Image) + ] + + await self.logger.info( + f'{event.message_chain}', + images=image_components, + message_session_id=f'person_{event.sender.id}', + ) + + # Push to webhooks and check if pipeline should be skipped + skip_pipeline = False + if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: + skip_pipeline = await self.ap.webhook_pusher.push_person_message( + event, self.bot_entity.uuid, adapter.__class__.__name__ + ) + + # Only add to query pool if no webhook requested to skip pipeline + if not skip_pipeline: + launcher_id = event.sender.id + + if hasattr(adapter, 'get_launcher_id'): + custom_launcher_id = adapter.get_launcher_id(event) + if custom_launcher_id: + launcher_id = custom_launcher_id + + message_text = str(event.message_chain) + element_types = [comp.type for comp in event.message_chain] + pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid( + 'person', launcher_id, message_text, element_types + ) + + if pipeline_uuid == self.PIPELINE_DISCARD: + await self.logger.info('Person message discarded by routing rule') + await self._record_discarded_message( + provider_session.LauncherTypes.PERSON, + launcher_id, + event.sender.id, + event, + event.message_chain, + ) + return + + await self.ap.msg_aggregator.add_message( + bot_uuid=self.bot_entity.uuid, + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id=launcher_id, + sender_id=event.sender.id, + message_event=event, + message_chain=event.message_chain, + adapter=adapter, + pipeline_uuid=pipeline_uuid, + routed_by_rule=routed_by_rule, + ) + else: + await self.logger.info('Pipeline skipped for person message due to webhook response') + + async def on_group_message( + event: platform_events.GroupMessage, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ): + image_components = [ + component for component in event.message_chain if isinstance(component, platform_message.Image) + ] + + await self.logger.info( + f'{event.message_chain}', + images=image_components, + message_session_id=f'group_{event.group.id}', + ) + + # Push to webhooks and check if pipeline should be skipped + skip_pipeline = False + if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher: + skip_pipeline = await self.ap.webhook_pusher.push_group_message( + event, self.bot_entity.uuid, adapter.__class__.__name__ + ) + + # Only add to query pool if no webhook requested to skip pipeline + if not skip_pipeline: + launcher_id = event.group.id + + if hasattr(adapter, 'get_launcher_id'): + custom_launcher_id = adapter.get_launcher_id(event) + if custom_launcher_id: + launcher_id = custom_launcher_id + + message_text = str(event.message_chain) + element_types = [comp.type for comp in event.message_chain] + pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid( + 'group', launcher_id, message_text, element_types + ) + + if pipeline_uuid == self.PIPELINE_DISCARD: + await self.logger.info('Group message discarded by routing rule') + await self._record_discarded_message( + provider_session.LauncherTypes.GROUP, + launcher_id, + event.sender.id, + event, + event.message_chain, + ) + return + + await self.ap.msg_aggregator.add_message( + bot_uuid=self.bot_entity.uuid, + launcher_type=provider_session.LauncherTypes.GROUP, + launcher_id=launcher_id, + sender_id=event.sender.id, + message_event=event, + message_chain=event.message_chain, + adapter=adapter, + pipeline_uuid=pipeline_uuid, + routed_by_rule=routed_by_rule, + ) + else: + await self.logger.info('Pipeline skipped for group message due to webhook response') + + self.adapter.register_listener(platform_events.FriendMessage, on_friend_message) + self.adapter.register_listener(platform_events.GroupMessage, on_group_message) + + # Register feedback listener (only effective on adapters that support it) + async def on_feedback( + event: platform_events.FeedbackEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ): + try: + # Resolve pipeline name + pipeline_name = '' + if self.bot_entity.use_pipeline_uuid: + try: + pipeline_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where( + persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid + ) + ) + pipeline_row = pipeline_result.first() + if pipeline_row: + pipeline_name = pipeline_row[0] + except Exception: + pass + + await self.ap.monitoring_service.record_feedback( + feedback_id=event.feedback_id, + feedback_type=event.feedback_type, + feedback_content=event.feedback_content, + inaccurate_reasons=event.inaccurate_reasons, + bot_id=self.bot_entity.uuid, + bot_name=self.bot_entity.name, + pipeline_id=self.bot_entity.use_pipeline_uuid or '', + pipeline_name=pipeline_name, + session_id=event.session_id, + message_id=event.message_id, + stream_id=event.stream_id, + user_id=event.user_id, + platform=adapter.__class__.__name__, + ) + await self.logger.info( + f'Recorded feedback: feedback_id={event.feedback_id}, type={event.feedback_type}' + ) + except Exception: + await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}') + + self.adapter.register_listener(platform_events.FeedbackEvent, on_feedback) + + async def run(self): + async def exception_wrapper(): + try: + self.task_context.set_current_action('Running...') + await self.adapter.run_async() + self.task_context.set_current_action('Exited.') + except Exception as e: + if isinstance(e, asyncio.CancelledError): + self.task_context.set_current_action('Exited.') + return + + traceback_str = traceback.format_exc() + self.task_context.set_current_action('Exited with error.') + await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback_str}') + + self.task_wrapper = self.ap.task_mgr.create_task( + exception_wrapper(), + kind='platform-adapter', + name=f'platform-adapter-{self.adapter.__class__.__name__}', + context=self.task_context, + scopes=[ + core_entities.LifecycleControlScope.APPLICATION, + core_entities.LifecycleControlScope.PLATFORM, + ], + ) + + async def shutdown(self): + await self.adapter.kill() + + self.ap.task_mgr.cancel_task(self.task_wrapper.id) + + +# 控制QQ消息输入输出的类 +class PlatformManager: + # ====== 4.0 ====== + ap: app.Application = None + + bots: list[RuntimeBot] + + websocket_proxy_bot: RuntimeBot + + adapter_components: list[engine.Component] + + adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] + + def __init__(self, ap: app.Application = None): + self.ap = ap + self.bots = [] + self.adapter_components = [] + self.adapter_dict = {} + + async def initialize(self): + # delete all bot log images + await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images') + + disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or [] + + self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter') + adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {} + for component in self.adapter_components: + if component.metadata.name in disabled_adapters: + continue + adapter_dict[component.metadata.name] = component.get_python_component_class() + self.adapter_dict = adapter_dict + + # Filter out disabled adapters from components list (for API responses) + if disabled_adapters: + self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters] + + # initialize websocket adapter + websocket_adapter_class = self.adapter_dict['websocket'] + websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap) + websocket_adapter_inst = websocket_adapter_class( + {}, + websocket_logger, + ap=self.ap, + ) + + self.websocket_proxy_bot = RuntimeBot( + ap=self.ap, + bot_entity=persistence_bot.Bot( + uuid='websocket-proxy-bot', + name='WebSocket', + description='', + adapter='websocket', + adapter_config={}, + enable=True, + ), + adapter=websocket_adapter_inst, + logger=websocket_logger, + ) + await self.websocket_proxy_bot.initialize() + + await self.load_bots_from_db() + + def get_running_adapters(self) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]: + return [bot.adapter for bot in self.bots if bot.enable] + + async def load_bots_from_db(self): + self.ap.logger.info('Loading bots from db...') + + self.bots = [] + + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot)) + + bots = result.all() + + for bot in bots: + # load all bots here, enable or disable will be handled in runtime + try: + await self.load_bot(bot) + except platform_errors.AdapterNotFoundError as e: + self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}') + except Exception as e: + self.ap.logger.error(f'Failed to load bot {bot.uuid}: {e}\n{traceback.format_exc()}') + + async def load_bot( + self, + bot_entity: persistence_bot.Bot | sqlalchemy.Row[persistence_bot.Bot] | dict, + ) -> RuntimeBot: + """加载机器人""" + if isinstance(bot_entity, sqlalchemy.Row): + bot_entity = persistence_bot.Bot(**bot_entity._mapping) + elif isinstance(bot_entity, dict): + bot_entity = persistence_bot.Bot(**bot_entity) + + logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap) + + if bot_entity.adapter not in self.adapter_dict: + raise platform_errors.AdapterNotFoundError(bot_entity.adapter) + + adapter_inst = self.adapter_dict[bot_entity.adapter]( + bot_entity.adapter_config, + logger, + ) + if hasattr(adapter_inst, 'ap'): + adapter_inst.ap = self.ap + + # 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook) + if hasattr(adapter_inst, 'set_bot_uuid'): + adapter_inst.set_bot_uuid(bot_entity.uuid) + + runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger) + + await runtime_bot.initialize() + + self.bots.append(runtime_bot) + + return runtime_bot + + async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None: + if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid: + return self.websocket_proxy_bot + for bot in self.bots: + if bot.bot_entity.uuid == bot_uuid: + return bot + return None + + async def remove_bot(self, bot_uuid: str): + for bot in self.bots[:]: + if bot.bot_entity.uuid == bot_uuid: + if bot.enable: + await bot.shutdown() + self.bots.remove(bot) + return + + def get_available_adapters_info(self) -> list[dict]: + return [ + component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'websocket' + ] + + def get_available_adapter_info_by_name(self, name: str) -> dict | None: + for component in self.adapter_components: + if component.metadata.name == name: + return component.to_plain_dict() + return None + + def get_available_adapter_manifest_by_name(self, name: str) -> engine.Component | None: + for component in self.adapter_components: + if component.metadata.name == name: + return component + return None + + async def run(self): + # This method will only be called when the application launching + await self.websocket_proxy_bot.run() + + for bot in self.bots: + if bot.enable: + await bot.run() + + async def shutdown(self): + for bot in self.bots: + if bot.enable: + await bot.shutdown() + self.ap.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.PLATFORM) diff --git a/src/langbot/pkg/platform/logger.py b/src/langbot/pkg/platform/logger.py new file mode 100644 index 0000000..6816486 --- /dev/null +++ b/src/langbot/pkg/platform/logger.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import typing +import mimetypes +import time +import enum +import pydantic +import traceback +import uuid + +from ..core import app +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_event_logger + + +class EventLogLevel(enum.Enum): + """日志级别""" + + DEBUG = 'debug' + INFO = 'info' + WARNING = 'warning' + ERROR = 'error' + + +class EventLog(pydantic.BaseModel): + seq_id: int + """日志序号""" + + timestamp: int + """日志时间戳""" + + level: EventLogLevel + """日志级别""" + + text: str + """日志文本""" + + images: typing.Optional[list[str]] = None + """日志图片 URL 列表,需要通过 /api/v1/image/{uuid} 获取图片""" + + message_session_id: typing.Optional[str] = None + """消息会话ID,仅收发消息事件有值""" + + def to_json(self) -> dict: + return { + 'seq_id': self.seq_id, + 'timestamp': self.timestamp, + 'level': self.level.value, + 'text': self.text, + 'images': self.images, + 'message_session_id': self.message_session_id, + } + + +MAX_LOG_COUNT = 200 +DELETE_COUNT_PER_TIME = 50 + + +class EventLogger(abstract_platform_event_logger.AbstractEventLogger): + """used for logging bot events""" + + ap: app.Application + + seq_id_inc: int + + logs: list[EventLog] + + def __init__( + self, + name: str, + ap: app.Application, + ): + self.name = name + self.ap = ap + self.logs = [] + self.seq_id_inc = 0 + + async def get_logs(self, from_seq_id: int, max_count: int) -> typing.Tuple[list[EventLog], int]: + """ + 获取日志,从 from_seq_id 开始获取 max_count 条历史日志 + + Args: + from_seq_id: 起始序号,-1 表示末尾 + max_count: 最大数量 + + Returns: + Tuple[list[EventLog], int]: 日志列表,日志总数 + """ + if len(self.logs) == 0: + return [], 0 + + if from_seq_id <= -1: + from_seq_id = self.logs[-1].seq_id + + min_seq_id_in_logs = self.logs[0].seq_id + max_seq_id_in_logs = self.logs[-1].seq_id + + if from_seq_id < min_seq_id_in_logs: # 需要的整个范围都已经被删除 + return [], len(self.logs) + + if ( + from_seq_id > max_seq_id_in_logs and from_seq_id - max_count > max_seq_id_in_logs + ): # 需要的整个范围都还没生成 + return [], len(self.logs) + + end_index = 1 + + for i, log in enumerate(self.logs): + if log.seq_id >= from_seq_id: + end_index = i + 1 + break + + start_index = max(0, end_index - max_count) + + if max_count > 0: + return self.logs[start_index:end_index], len(self.logs) + else: + return [], len(self.logs) + + async def _truncate_logs(self): + if len(self.logs) > MAX_LOG_COUNT: + for i in range(DELETE_COUNT_PER_TIME): + for image_key in self.logs[i].images: # type: ignore + await self.ap.storage_mgr.storage_provider.delete(image_key) + self.logs = self.logs[DELETE_COUNT_PER_TIME:] + + async def _add_log( + self, + level: EventLogLevel, + text: str, + images: typing.Optional[list[platform_message.Image]] = None, + message_session_id: typing.Optional[str] = None, + no_throw: bool = True, + ): + try: + image_keys = [] + + if images is None: + images = [] + + if message_session_id is None: + message_session_id = '' + + if not isinstance(message_session_id, str): + message_session_id = str(message_session_id) + + for img in images: + img_bytes, mime_type = await img.get_bytes() + extension = mimetypes.guess_extension(mime_type) + if extension is None: + extension = '.jpg' + image_key = f'bot_log_images/{message_session_id}-{uuid.uuid4()}{extension}' + await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes) + image_keys.append(image_key) + + self.logs.append( + EventLog( + seq_id=self.seq_id_inc, + timestamp=int(time.time()), + level=level, + text=text, + images=image_keys, + message_session_id=message_session_id, + ) + ) + self.seq_id_inc += 1 + + await self._truncate_logs() + + except Exception as e: + if not no_throw: + raise e + else: + traceback.print_exc() + + async def info( + self, + text: str, + images: typing.Optional[list[platform_message.Image]] = None, + message_session_id: typing.Optional[str] = None, + no_throw: bool = True, + ): + await self._add_log( + level=EventLogLevel.INFO, + text=text, + images=images, + message_session_id=message_session_id, + no_throw=no_throw, + ) + + async def debug( + self, + text: str, + images: typing.Optional[list[platform_message.Image]] = None, + message_session_id: typing.Optional[str] = None, + no_throw: bool = True, + ): + await self._add_log( + level=EventLogLevel.DEBUG, + text=text, + images=images, + message_session_id=message_session_id, + no_throw=no_throw, + ) + + async def warning( + self, + text: str, + images: typing.Optional[list[platform_message.Image]] = None, + message_session_id: typing.Optional[str] = None, + no_throw: bool = True, + ): + await self._add_log( + level=EventLogLevel.WARNING, + text=text, + images=images, + message_session_id=message_session_id, + no_throw=no_throw, + ) + + async def error( + self, + text: str, + images: typing.Optional[list[platform_message.Image]] = None, + message_session_id: typing.Optional[str] = None, + no_throw: bool = True, + ): + await self._add_log( + level=EventLogLevel.ERROR, + text=text, + images=images, + message_session_id=message_session_id, + no_throw=no_throw, + ) diff --git a/src/langbot/pkg/platform/sources/__init__.py b/src/langbot/pkg/platform/sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/langbot/pkg/platform/sources/aiocqhttp.py b/src/langbot/pkg/platform/sources/aiocqhttp.py new file mode 100644 index 0000000..abe1ca9 --- /dev/null +++ b/src/langbot/pkg/platform/sources/aiocqhttp.py @@ -0,0 +1,682 @@ +from __future__ import annotations +import typing +import asyncio +import traceback +import datetime +import json +import time + +import aiocqhttp +import pydantic + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +from ...utils import image +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + + +_GROUP_NAME_CACHE_TTL_SECONDS = 3600 +_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60 +_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2 +_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400 +_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600 +_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2 + + +def _normalize_base64_payload(value: str) -> str: + if value.startswith('base64://'): + return value.removeprefix('base64://') + if value.startswith('data:') and ';base64,' in value: + return value.split(';base64,', 1)[1] + return value + + +def _get_field(data: dict, key: str, default: str = '') -> str: + value = data.get(key) + if value is None: + return default + return str(value) + + +def _get_group_member_name(sender: dict) -> str: + return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id') + + +def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str: + return f'Group {group_id}' + + +class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain, + ) -> typing.Tuple[list, int, datetime.datetime]: + msg_list = aiocqhttp.Message() + + msg_id = 0 + msg_time = None + + for msg in message_chain: + if type(msg) is platform_message.Plain: + msg_list.append(aiocqhttp.MessageSegment.text(msg.text)) + elif type(msg) is platform_message.Source: + msg_id = msg.id + msg_time = msg.time + elif type(msg) is platform_message.Image: + arg = '' + if msg.base64: + arg = _normalize_base64_payload(msg.base64) + msg_list.append(aiocqhttp.MessageSegment.image(f'base64://{arg}')) + elif msg.url: + arg = msg.url + msg_list.append(aiocqhttp.MessageSegment.image(arg)) + elif msg.path: + arg = msg.path + msg_list.append(aiocqhttp.MessageSegment.image(arg)) + elif type(msg) is platform_message.At: + msg_list.append(aiocqhttp.MessageSegment.at(msg.target)) + elif type(msg) is platform_message.AtAll: + msg_list.append(aiocqhttp.MessageSegment.at('all')) + elif type(msg) is platform_message.Voice: + arg = '' + if msg.base64: + arg = _normalize_base64_payload(msg.base64) + msg_list.append(aiocqhttp.MessageSegment.record(f'base64://{arg}')) + elif msg.url: + arg = msg.url + msg_list.append(aiocqhttp.MessageSegment.record(arg)) + elif msg.path: + arg = msg.path + msg_list.append(aiocqhttp.MessageSegment.record(msg.path)) + elif type(msg) is platform_message.Forward: + for node in msg.node_list: + msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0]) + elif isinstance(msg, platform_message.File): + file = msg.url or msg.path + if not file and msg.base64: + file = f'base64://{_normalize_base64_payload(msg.base64)}' + msg_list.append({'type': 'file', 'data': {'file': file, 'name': msg.name}}) + elif isinstance(msg, platform_message.Face): + if msg.face_type == 'face': + msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id)) + elif msg.face_type == 'rps': + msg_list.append(aiocqhttp.MessageSegment.rps()) + elif msg.face_type == 'dice': + msg_list.append(aiocqhttp.MessageSegment.dice()) + + else: + msg_list.append(aiocqhttp.MessageSegment.text(str(msg))) + + return msg_list, msg_id, msg_time + + @staticmethod + async def target2yiri(message: str, message_id: int = -1, bot: aiocqhttp.CQHttp = None): + message = aiocqhttp.Message(message) + + def get_face_name(face_id): + face_code_dict = { + '2': '好色', + '4': '得意', + '5': '流泪', + '8': '睡', + '9': '大哭', + '10': '尴尬', + '12': '调皮', + '14': '微笑', + '16': '酷', + '21': '可爱', + '23': '傲慢', + '24': '饥饿', + '25': '困', + '26': '惊恐', + '27': '流汗', + '28': '憨笑', + '29': '悠闲', + '30': '奋斗', + '32': '疑问', + '33': '嘘', + '34': '晕', + '38': '敲打', + '39': '再见', + '41': '发抖', + '42': '爱情', + '43': '跳跳', + '49': '拥抱', + '53': '蛋糕', + '60': '咖啡', + '63': '玫瑰', + '66': '爱心', + '74': '太阳', + '75': '月亮', + '76': '赞', + '78': '握手', + '79': '胜利', + '85': '飞吻', + '89': '西瓜', + '96': '冷汗', + '97': '擦汗', + '98': '抠鼻', + '99': '鼓掌', + '100': '糗大了', + '101': '坏笑', + '102': '左哼哼', + '103': '右哼哼', + '104': '哈欠', + '106': '委屈', + '109': '左亲亲', + '111': '可怜', + '116': '示爱', + '118': '抱拳', + '120': '拳头', + '122': '爱你', + '123': 'NO', + '124': 'OK', + '125': '转圈', + '129': '挥手', + '144': '喝彩', + '147': '棒棒糖', + '171': '茶', + '173': '泪奔', + '174': '无奈', + '175': '卖萌', + '176': '小纠结', + '179': 'doge', + '180': '惊喜', + '181': '骚扰', + '182': '笑哭', + '183': '我最美', + '201': '点赞', + '203': '托脸', + '212': '托腮', + '214': '啵啵', + '219': '蹭一蹭', + '222': '抱抱', + '227': '拍手', + '232': '佛系', + '240': '喷脸', + '243': '甩头', + '246': '加油抱抱', + '262': '脑阔疼', + '264': '捂脸', + '265': '辣眼睛', + '266': '哦哟', + '267': '头秃', + '268': '问号脸', + '269': '暗中观察', + '270': 'emm', + '271': '吃瓜', + '272': '呵呵哒', + '273': '我酸了', + '277': '汪汪', + '278': '汗', + '281': '无眼笑', + '282': '敬礼', + '284': '面无表情', + '285': '摸鱼', + '287': '哦', + '289': '睁眼', + '290': '敲开心', + '293': '摸锦鲤', + '294': '期待', + '297': '拜谢', + '298': '元宝', + '299': '牛啊', + '305': '右亲亲', + '306': '牛气冲天', + '307': '喵喵', + '314': '仔细分析', + '315': '加油', + '318': '崇拜', + '319': '比心', + '320': '庆祝', + '322': '拒绝', + '324': '吃糖', + '326': '生气', + } + return face_code_dict.get(face_id, '') + + async def process_message_data(msg_data, reply_list): + if msg_data['type'] == 'image': + image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url']) + reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')) + + elif msg_data['type'] == 'text': + reply_list.append(platform_message.Plain(text=msg_data['data']['text'])) + + elif msg_data['type'] == 'forward': # 这里来应该传入转发消息组,暂时传入Quote + for forward_msg_datas in msg_data['data']['content']: + for forward_msg_data in forward_msg_datas['message']: + await process_message_data(forward_msg_data, reply_list) + + elif msg_data['type'] == 'at': + if msg_data['data']['qq'] == 'all': + reply_list.append(platform_message.AtAll()) + else: + reply_list.append( + platform_message.At( + target=msg_data['data']['qq'], + ) + ) + + yiri_msg_list = [] + + yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now())) + + for msg in message: + reply_list = [] + if msg.type == 'at': + if msg.data['qq'] == 'all': + yiri_msg_list.append(platform_message.AtAll()) + else: + yiri_msg_list.append( + platform_message.At( + target=msg.data['qq'], + ) + ) + elif msg.type == 'text': + yiri_msg_list.append(platform_message.Plain(text=msg.data['text'])) + elif msg.type == 'image': + emoji_id = msg.data.get('emoji_package_id', None) + if emoji_id: + face_id = emoji_id + face_name = msg.data.get('summary', '') + image_msg = platform_message.Face(face_id=face_id, face_name=face_name) + else: + image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url']) + image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}') + yiri_msg_list.append(image_msg) + elif msg.type == 'forward': + # 暂时不太合理 + # msg_datas = await bot.get_msg(message_id=message_id) + # print(msg_datas) + # for msg_data in msg_datas["message"]: + # await process_message_data(msg_data, yiri_msg_list) + pass + + elif msg.type == 'reply': # 此处处理引用消息传入Quote + msg_datas = await bot.get_msg(message_id=msg.data['id']) + + for msg_data in msg_datas['message']: + await process_message_data(msg_data, reply_list) + + reply_msg = platform_message.Quote( + message_id=msg.data['id'], sender_id=msg_datas['user_id'], origin=reply_list + ) + yiri_msg_list.append(reply_msg) + + elif msg.type == 'file': + pass + # file_name = msg.data['file'] + # file_id = msg.data['file_id'] + # file_data = await bot.get_file(file_id=file_id) + # file_name = file_data.get('file_name') + # file_path = file_data.get('file') + # _ = file_path + # file_url = file_data.get('file_url') + # file_size = file_data.get('file_size') + # yiri_msg_list.append(platform_message.File(id=file_id, name=file_name,url=file_url,size=file_size)) + elif msg.type == 'face': + face_id = msg.data['id'] + face_name = msg.data['raw']['faceText'] + if not face_name: + face_name = get_face_name(face_id) + yiri_msg_list.append(platform_message.Face(face_id=int(face_id), face_name=face_name.replace('/', ''))) + elif msg.type == 'rps': + face_id = msg.data['result'] + yiri_msg_list.append(platform_message.Face(face_type='rps', face_id=int(face_id), face_name='猜拳')) + elif msg.type == 'dice': + face_id = msg.data['result'] + yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子')) + elif msg.type == 'json': + try: + raw = msg.data.get('data', {}) + if isinstance(raw, str): + raw = json.loads(raw) + if isinstance(raw, dict): + _meta = raw.get('meta', {}) or {} + if isinstance(_meta, dict): + _detail = _meta.get('detail_1') or _meta.get('music') or _meta.get('news') or {} + else: + _detail = {} + if isinstance(_detail, dict): + preview = _detail.get('preview', '') + title = _detail.get('desc', '') or _detail.get('title', '') + url = _detail.get('qqdocurl', '') or _detail.get('jumpUrl', '') + else: + preview = title = url = '' + text = ' '.join([f'[{raw.get("app", "")}]', preview, title, url]).strip() + yiri_msg_list.append(platform_message.Plain(text=text or '[收到一张JSON卡片]')) + else: + yiri_msg_list.append(platform_message.Plain(text=str(raw))) + except Exception: + yiri_msg_list.append(platform_message.Plain(text='[收到一张JSON卡片]')) + + chain = platform_message.MessageChain(yiri_msg_list) + + return chain + + +class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): + def __init__(self): + self._group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {} + self._group_name_negative_cache: dict[typing.Union[int, str], float] = {} + self._group_member_info_cache: dict[ + tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float] + ] = {} + self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {} + + @staticmethod + async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int): + return event.source_platform_object + + async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str: + now = time.monotonic() + if group_id in self._group_name_cache: + group_name, expires_at = self._group_name_cache[group_id] + if expires_at > now: + return group_name + del self._group_name_cache[group_id] + if group_id in self._group_name_negative_cache: + expires_at = self._group_name_negative_cache[group_id] + if expires_at > now: + return '' + del self._group_name_negative_cache[group_id] + if bot is None: + return '' + try: + group_info = await asyncio.wait_for( + bot.get_group_info(group_id=group_id), + timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS, + ) + except Exception: + self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS + return '' + group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else '' + if group_name: + self._group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS) + self._group_name_negative_cache.pop(group_id, None) + else: + self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS + return group_name + + async def _get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + bot=None, + ) -> dict: + now = time.monotonic() + cache_key = (group_id, user_id) + if cache_key in self._group_member_info_cache: + member_info, expires_at = self._group_member_info_cache[cache_key] + if expires_at > now: + return member_info + del self._group_member_info_cache[cache_key] + if cache_key in self._group_member_info_negative_cache: + expires_at = self._group_member_info_negative_cache[cache_key] + if expires_at > now: + return {} + del self._group_member_info_negative_cache[cache_key] + if bot is None: + return {} + try: + member_info = await asyncio.wait_for( + bot.get_group_member_info(group_id=group_id, user_id=user_id), + timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS, + ) + except Exception: + self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS + return {} + if isinstance(member_info, dict) and member_info: + self._group_member_info_cache[cache_key] = ( + member_info, + now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS, + ) + self._group_member_info_negative_cache.pop(cache_key, None) + return member_info + self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS + return {} + + async def target2yiri(self, event: aiocqhttp.Event, bot=None): + yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot) + + if event.message_type == 'group': + permission = 'MEMBER' + group_name = await self._get_group_name(event.group_id, bot) or _get_group_name_placeholder(event.group_id) + special_title = _get_field(event.sender, 'title') + if not special_title: + member_info = await self._get_group_member_info(event.group_id, event.sender['user_id'], bot) + special_title = _get_field(member_info, 'title') + + if 'role' in event.sender: + if event.sender['role'] == 'admin': + permission = 'ADMINISTRATOR' + elif event.sender['role'] == 'owner': + permission = 'OWNER' + converted_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=event.sender['user_id'], # message_seq 放哪? + member_name=_get_group_member_name(event.sender), + permission=permission, + group=platform_entities.Group( + id=event.group_id, + name=group_name, + permission=platform_entities.Permission.Member, + ), + special_title=special_title, + ), + message_chain=yiri_chain, + time=event.time, + source_platform_object=event, + ) + return converted_event + elif event.message_type == 'private': + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.sender['user_id'], + nickname=event.sender['nickname'], + remark='', + ), + message_chain=yiri_chain, + time=event.time, + source_platform_object=event, + ) + + +class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp) + + message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() + event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter) + + on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = [] + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + super().__init__( + config=config, + logger=logger, + ) + + async def shutdown_trigger_placeholder(): + while True: + await asyncio.sleep(1) + + self.config['shutdown_trigger'] = shutdown_trigger_placeholder + + self.on_websocket_connection_event_cache = [] + + if 'access-token' in config: + self.bot = aiocqhttp.CQHttp(access_token=config['access-token']) + del self.config['access-token'] + else: + self.bot = aiocqhttp.CQHttp() + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + # Check if message contains a Forward component + forward_msg = message.get_first(platform_message.Forward) + if forward_msg: + if target_type == 'group': + # Send as merged forward message via OneBot API + await self._send_forward_message(int(target_id), forward_msg) + return + else: + await self.logger.warning( + f'Forward message is only supported for group targets, got target_type={target_type}. Falling through to normal send.' + ) + + aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0] + + if target_type == 'group': + await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg) + elif target_type == 'person': + await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg) + + async def _send_forward_message(self, group_id: int, forward: platform_message.Forward): + """Send a merged forward message to a group using NapCat extended API.""" + messages = [] + + for node in forward.node_list: + # Build content for each node + content = [] + if node.message_chain: + for component in node.message_chain: + if isinstance(component, platform_message.Plain): + if component.text: + content.append({'type': 'text', 'data': {'text': component.text}}) + elif isinstance(component, platform_message.Image): + img_data = {} + if component.base64: + b64 = _normalize_base64_payload(component.base64) + img_data['file'] = f'base64://{b64}' + elif component.url: + img_data['file'] = component.url + elif component.path: + img_data['file'] = str(component.path) + + if img_data: + content.append({'type': 'image', 'data': img_data}) + + if not content: + continue + + # Build node data - use user_id and nickname format for NapCat + user_id = str(node.sender_id) if node.sender_id else str(self.bot_account_id or '10000') + node_data = { + 'type': 'node', + 'data': { + 'user_id': user_id, + 'nickname': node.sender_name or '未知', + 'content': content, + }, + } + + messages.append(node_data) + + if not messages: + return + + # Build the full message payload for NapCat's send_forward_msg API + # This matches the format used by GiveMeSetuPlugin + bot_id = str(self.bot_account_id) if self.bot_account_id else '10000' + payload = { + 'group_id': group_id, + 'user_id': bot_id, # Required by NapCat for display + 'messages': messages, + } + + # Add display settings if available + if forward.display: + if forward.display.title: + payload['news'] = [{'text': forward.display.title}] + if forward.display.brief: + payload['prompt'] = forward.display.brief + if forward.display.summary: + payload['summary'] = forward.display.summary + if forward.display.source: + payload['source'] = forward.display.source + + try: + # Use send_forward_msg (NapCat extended API) instead of send_group_forward_msg + await self.logger.info( + f'Sending forward message to group {group_id} with {len(messages)} nodes, payload keys: {list(payload.keys())}' + ) + result = await self.bot.call_action('send_forward_msg', **payload) + await self.logger.info(f'Forward message sent to group {group_id}, result: {result}') + except Exception as e: + await self.logger.error(f'Failed to send forward message to group {group_id}: {e}') + # Fallback: try standard OneBot API with integer group_id + try: + await self.logger.info('Trying fallback API send_group_forward_msg') + await self.bot.call_action('send_group_forward_msg', group_id=group_id, messages=messages) + await self.logger.info(f'Forward message sent via fallback API to group {group_id}') + except Exception as e2: + await self.logger.error(f'Fallback also failed: {e2}') + raise + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + aiocq_event = await AiocqhttpEventConverter.yiri2target(message_source, self.bot_account_id) + aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0] + if quote_origin: + aiocq_msg = aiocqhttp.MessageSegment.reply(aiocq_event.message_id) + aiocq_msg + + return await self.bot.send(aiocq_event, aiocq_msg) + + async def is_muted(self, group_id: int) -> bool: + return False + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + async def on_message(event: aiocqhttp.Event): + self.bot_account_id = event.self_id + try: + return await callback(await self.event_converter.target2yiri(event, self.bot), self) + except Exception: + await self.logger.error(f'Error in on_message: {traceback.format_exc()}') + traceback.print_exc() + + if event_type == platform_events.GroupMessage: + self.bot.on_message('group')(on_message) + # self.bot.on_notice()(on_message) + elif event_type == platform_events.FriendMessage: + self.bot.on_message('private')(on_message) + # self.bot.on_notice()(on_message) + # print(event_type) + + async def on_websocket_connection(event: aiocqhttp.Event): + for event in self.on_websocket_connection_event_cache: + if event.self_id == event.self_id and event.time == event.time: + return + + self.on_websocket_connection_event_cache.append(event) + await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}') + + self.bot.on_websocket_connection(on_websocket_connection) + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + return super().unregister_listener(event_type, callback) + + async def run_async(self): + await self.bot._server_app.run_task(**self.config) + + async def kill(self) -> bool: + # Current issue: existing connection will not be closed + # self.should_shutdown = True + return False diff --git a/src/langbot/pkg/platform/sources/aiocqhttp.yaml b/src/langbot/pkg/platform/sources/aiocqhttp.yaml new file mode 100644 index 0000000..b93a252 --- /dev/null +++ b/src/langbot/pkg/platform/sources/aiocqhttp.yaml @@ -0,0 +1,61 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: aiocqhttp + label: + en_US: OneBot v11 + zh_Hans: OneBot v11 + zh_Hant: OneBot v11 + description: + en_US: OneBot v11 Adapter, used for QQ bots + zh_Hans: OneBot v11 适配器,用于接入 QQ 机器人协议端,请查看文档了解使用方式 + zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式 + icon: onebot.png +spec: + categories: + - protocol + help_links: + zh: https://link.langbot.app/zh/platforms/aiocqhttp + en: https://link.langbot.app/en/platforms/aiocqhttp + ja: https://link.langbot.app/ja/platforms/aiocqhttp + config: + - name: host + label: + en_US: Host + zh_Hans: 主机 + zh_Hant: 主機 + description: + en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0 + zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0 + zh_Hant: OneBot v11 監聽的反向 WS 主機,除非你知道自己在做什麼,否則請填 0.0.0.0 + type: string + required: true + default: 0.0.0.0 + - name: port + label: + en_US: Port + zh_Hans: 端口 + zh_Hant: 連接埠 + description: + en_US: Port + zh_Hans: 监听的端口 + zh_Hant: 監聽的連接埠 + type: integer + required: true + default: 2280 + - name: access-token + label: + en_US: Access Token + zh_Hans: 访问令牌 + zh_Hant: 存取令牌 + description: + en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it + zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填 + zh_Hant: 自訂的與協定端的連線令牌,若協定端未設定,則不填 + type: string + required: false + default: "" +execution: + python: + path: ./aiocqhttp.py + attr: AiocqhttpAdapter \ No newline at end of file diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py new file mode 100644 index 0000000..996187f --- /dev/null +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -0,0 +1,1530 @@ +import asyncio +import json +import pathlib +import re +import traceback +import typing +import uuid + +from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.provider.session as provider_session +from langbot.libs.dingtalk_api.api import DingTalkClient +import datetime +from langbot.pkg.platform.logger import EventLogger +from langbot.pkg.provider.runners.difysvapi import _format_human_input_text + + +class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + def _format_image_as_markdown(msg: platform_message.Image) -> str: + """Convert an Image message to Markdown format for DingTalk.""" + if msg.url: + return f'\n![image]({msg.url})\n' + elif msg.base64: + # For base64 images, try to include them as data URIs + # DingTalk may have limited support for base64 in markdown + if msg.base64.startswith('data:'): + return f'\n![image]({msg.base64})\n' + else: + return f'\n![image](data:image/png;base64,{msg.base64})\n' + return '' + + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain, markdown_enabled: bool = True): + content = '' + at = False + for msg in message_chain: + if type(msg) is platform_message.At: + at = True + elif type(msg) is platform_message.Plain: + content += msg.text + elif type(msg) is platform_message.Image: + # DingTalk supports markdown images when markdown_card is enabled + # When markdown is disabled, images cannot be rendered in plain text mode + if markdown_enabled: + content += DingTalkMessageConverter._format_image_as_markdown(msg) + # Note: When markdown_enabled is False, images are not included + # as DingTalk plain text messages don't support image embedding + elif type(msg) is platform_message.Forward: + for node in msg.node_list: + forwarded_content, _ = await DingTalkMessageConverter.yiri2target( + node.message_chain, markdown_enabled + ) + content += forwarded_content + return content, at + + @staticmethod + async def target2yiri(event: DingTalkEvent, bot_name: str): + yiri_msg_list = [] + yiri_msg_list.append( + platform_message.Source(id=event.incoming_message.message_id, time=datetime.datetime.now()) + ) + + for atUser in event.incoming_message.at_users: + if atUser.dingtalk_id == event.incoming_message.chatbot_user_id: + yiri_msg_list.append(platform_message.At(target=bot_name)) + + if event.rich_content: + elements = event.rich_content.get('Elements') + for element in elements: + if element.get('Type') == 'text': + text = element.get('Content', '').replace('@' + bot_name, '') + if text.strip(): + yiri_msg_list.append(platform_message.Plain(text=text)) + elif element.get('Type') == 'image' and element.get('Picture'): + yiri_msg_list.append(platform_message.Image(base64=element['Picture'])) + else: + # 回退到原有简单逻辑 + # 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理 + if event.content and event.type != 'audio': + text_content = event.content.replace('@' + bot_name, '') + yiri_msg_list.append(platform_message.Plain(text=text_content)) + if event.picture: + yiri_msg_list.append(platform_message.Image(base64=event.picture)) + + # 处理其他类型消息(文件、音频等) + if event.file: + yiri_msg_list.append(platform_message.File(url=event.file, name=event.name)) + if event.audio: + # 优先使用钉钉自带的语音转写文字(recognition字段) + if event.content and event.type == 'audio': + yiri_msg_list.append(platform_message.Plain(text=event.content)) + else: + yiri_msg_list.append(platform_message.Voice(base64=event.audio)) + + # Handle quoted/replied message - extract content as top-level components + # so that plugins like FileReader can process them the same way as direct messages + if event.quoted_message: + quote_info = event.quoted_message + msg_type = quote_info.get('msg_type', '') + + # Process quoted file - add as top-level File component (same as private chat) + if msg_type == 'file' and quote_info.get('file_url'): + file_name = quote_info.get('file_name', 'file') + yiri_msg_list.append(platform_message.File(url=quote_info['file_url'], name=file_name)) + + # Process quoted image - add as top-level Image component + elif msg_type == 'picture' and quote_info.get('picture'): + yiri_msg_list.append(platform_message.Image(base64=quote_info['picture'])) + + # Process quoted audio - add as top-level Voice component + elif msg_type == 'audio' and quote_info.get('audio'): + yiri_msg_list.append(platform_message.Voice(base64=quote_info['audio'])) + + # Process quoted text - add as Plain text with context prefix + elif msg_type == 'text' and quote_info.get('content'): + yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}')) + + # Process quoted rich text - add as Plain text with context prefix + elif msg_type == 'richText' and quote_info.get('content'): + yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}')) + + chain = platform_message.MessageChain(yiri_msg_list) + + return chain + + +class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.MessageEvent): + return event.source_platform_object + + @staticmethod + async def target2yiri(event: DingTalkEvent, bot_name: str): + message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name) + + if event.conversation == 'FriendMessage': + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.incoming_message.sender_staff_id, + nickname=event.incoming_message.sender_nick, + remark='', + ), + message_chain=message_chain, + time=event.incoming_message.create_at, + source_platform_object=event, + ) + elif event.conversation == 'GroupMessage': + sender = platform_entities.GroupMember( + id=event.incoming_message.sender_staff_id, + member_name=event.incoming_message.sender_nick, + permission='MEMBER', + group=platform_entities.Group( + id=event.incoming_message.conversation_id, + name=event.incoming_message.conversation_title, + permission=platform_entities.Permission.Member, + ), + special_title='', + ) + time = event.incoming_message.create_at + return platform_events.GroupMessage( + sender=sender, + message_chain=message_chain, + time=time, + source_platform_object=event, + ) + + +def _dingtalk_input_hint_lines(form_data: dict) -> list[str]: + lines: list[str] = [] + current_field = str(form_data.get('_current_input_field') or '').strip() + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + field_type = str(field.get('type') or 'text').strip().lower() + if current_field and field_name != current_field: + continue + if not field_name: + continue + if field_type == 'select': + source = field.get('option_source') or {} + options = source.get('value') if isinstance(source, dict) else [] + if isinstance(options, list) and options: + option_text = ', '.join(f'{idx}. {option}' for idx, option in enumerate(options, start=1)) + lines.append(f'- {field_name}: {option_text}') + else: + lines.append(f'- {field_name}: choose one option') + elif field_type in {'file', 'file-list'}: + limit = field.get('number_limits') if field_type == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if field_type == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + lines.append(f'- {field_name}: upload file(s){allowed}{suffix} or reply `{field_name}: `') + else: + lines.append(f'- {field_name}: reply `{field_name}: `') + return lines + + +def _dingtalk_pending_input_defs(form_data: dict) -> list[dict]: + if form_data.get('_action_select_only'): + return [] + inputs = form_data.get('inputs') or {} + pending = [] + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + if inputs.get(field_name) in (None, '', []): + pending.append(field) + return pending + + +def _dingtalk_clean_form_content(form_data: dict) -> str: + is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') + raw_content = str(form_data.get('raw_form_content') or '') + content = raw_content or form_data.get('form_content') or '' + input_defs = _dingtalk_form_input_defs(form_data) + field_names = {_dingtalk_field_name(field) for field in input_defs if _dingtalk_field_name(field)} + + if is_field_step and raw_content: + current_field = str(form_data.get('_current_input_field') or '').strip() + current_placeholder = next( + ( + match + for match in re.finditer(r'\{\{#\$output\.([^#{}]+)#\}\}', raw_content) + if match.group(1).strip() == current_field + ), + None, + ) + content = ( + raw_content[: current_placeholder.end()] if current_placeholder else form_data.get('form_content') or '' + ) + + if form_data.get('_action_select_only') or is_field_step: + fields = {_dingtalk_field_name(field): field for field in input_defs if _dingtalk_field_name(field)} + inputs = form_data.get('inputs') or {} + + def replace_placeholder(match: re.Match[str]) -> str: + field_name = match.group(1).strip() + field = fields.get(field_name) + if not field or inputs.get(field_name) in (None, '', []): + return '' + lines = _dingtalk_completed_input_lines( + { + 'input_defs': [field], + 'inputs': {field_name: inputs[field_name]}, + } + ) + return lines[0] if lines else '' + + content = re.sub(r'\{\{#\$output\.([^#{}]+)#\}\}', replace_placeholder, str(content)) + + kept_lines: list[str] = [] + for line in str(content).splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1) in field_names: + continue + kept_lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) + + +def _dingtalk_card_markdown(content: str) -> str: + """Preserve line breaks inside DingTalk card-template markdown slots.""" + return '
'.join(str(content or '').splitlines()) + + +def _dingtalk_form_input_defs(form_data: dict) -> list[dict]: + return list(form_data.get('all_input_defs') or form_data.get('input_defs') or []) + + +def _dingtalk_display_input_value(field: dict, value: typing.Any) -> str: + field_type = _dingtalk_field_type(field) + if field_type == 'file': + if isinstance(value, dict): + return value.get('url') or value.get('upload_file_id') or '1 file' + return str(value) + if field_type == 'file-list': + if isinstance(value, list): + return f'{len(value)} file(s)' + return str(value) + return str(value) + + +def _dingtalk_completed_input_lines(form_data: dict) -> list[str]: + inputs = form_data.get('inputs') or {} + if not isinstance(inputs, dict): + return [] + + lines: list[str] = [] + for field in _dingtalk_form_input_defs(form_data): + field_name = _dingtalk_field_name(field) + if not field_name: + continue + value = inputs.get(field_name) + if value in (None, '', []): + continue + display_value = _dingtalk_display_input_value(field, value) + lines.append(f'✅ {field_name}:{display_value}') + return lines + + +def _dingtalk_missing_completed_input_lines(form_data: dict, form_content: str) -> list[str]: + """Return completed values that are not already rendered in the form body.""" + rendered_lines = { + line.strip() + for line in re.split(r'|\r?\n', str(form_content or ''), flags=re.IGNORECASE) + if line.strip() + } + return [line for line in _dingtalk_completed_input_lines(form_data) if line.strip() not in rendered_lines] + + +def _dingtalk_supports_native_field(form_data: dict) -> bool: + current_name = str(form_data.get('_current_input_field') or '').strip() + if not current_name or form_data.get('_action_select_only'): + return False + for field in _dingtalk_form_input_defs(form_data): + if str(field.get('output_variable_name') or '').strip() != current_name: + continue + return str(field.get('type') or 'text').strip().lower() not in {'file', 'file-list'} + return False + + +def _dingtalk_field_name(field: dict) -> str: + return str(field.get('output_variable_name') or field.get('name') or field.get('id') or '').strip() + + +def _dingtalk_field_type(field: dict) -> str: + return str(field.get('type') or 'text').strip().lower() + + +def _dingtalk_select_options(field: dict) -> list[str]: + source = field.get('option_source') or {} + value = source.get('value') if isinstance(source, dict) else None + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [part.strip() for part in value.splitlines() if part.strip()] + options = field.get('options') + if isinstance(options, list): + result = [] + for item in options: + if isinstance(item, dict): + result.append(str(item.get('label') or item.get('value') or '')) + else: + result.append(str(item)) + return [item for item in result if item] + return [] + + +def _dingtalk_select_block_options(options: list[str]) -> list[dict]: + """Build the option shape consumed by DingTalk SelectBlock templates.""" + locales = ( + 'zh_CN', + 'zh_TW', + 'en_US', + 'ja_JP', + 'vi_VN', + 'th_TH', + 'id_ID', + 'ne_NP', + 'ms_MY', + 'ko_KR', + 'ru_RU', + 'es_EA', + 'tr_TR', + 'fr_FR', + 'pt_BR', + ) + return [{'value': option, 'text': {locale: option for locale in locales}} for option in options] + + +def _dingtalk_current_input_field(form_data: dict) -> dict | None: + current_name = str(form_data.get('_current_input_field') or '').strip() + if not current_name or form_data.get('_action_select_only'): + return None + for field in _dingtalk_form_input_defs(form_data): + if _dingtalk_field_name(field) == current_name: + return field + return None + + +def _dingtalk_form_component_params(form_data: dict) -> dict: + field = _dingtalk_current_input_field(form_data) + params = { + 'input_visible': '', + 'input_title': '', + 'input_placeholder': '', + 'input_value': '', + 'select_visible': '', + 'select_placeholder': '', + 'select_options': [], + 'index_o': [], + 'test_index': [], + 'select_index': -1, + } + if not field: + return params + + field_name = _dingtalk_field_name(field) + field_type = _dingtalk_field_type(field) + value = form_data.get('inputs', {}).get(field_name, '') + if field_type == 'select': + options = _dingtalk_select_options(field) + selected_index = -1 + if value not in (None, ''): + try: + selected_index = options.index(str(value)) + except ValueError: + selected_index = -1 + params.update( + { + 'select_visible': 'true', + 'select_placeholder': field_name or 'Select', + 'select_options': options, + 'index_o': _dingtalk_select_block_options(options), + 'test_index': _dingtalk_select_block_options(options), + 'select_index': selected_index, + } + ) + elif field_type not in {'file', 'file-list'}: + params.update( + { + 'input_visible': 'true', + 'input_title': field_name or 'Input', + 'input_placeholder': field_name or 'Input', + 'input_value': '' if value is None else str(value), + } + ) + return params + + +def _dingtalk_empty_form_component_params() -> dict: + return _dingtalk_form_component_params({}) + + +def _dingtalk_extract_component_inputs(params: dict) -> dict: + """Normalize DingTalk native Input/SelectBlock callback payloads.""" + if not isinstance(params, dict): + return {} + + result = {} + input_value = params.get('input') + if input_value in (None, ''): + input_result = params.get('inputResult') + if isinstance(input_result, dict): + input_value = input_result.get('value') or input_result.get('input') + elif input_result not in (None, ''): + input_value = input_result + if input_value not in (None, ''): + if isinstance(input_value, str): + input_value = input_value.strip() + result['input'] = input_value + + select_value = params.get('select') + if select_value in (None, ''): + for key in ('selectResult', 'select_result', '__built_in_selectResult__'): + candidate = params.get(key) + if candidate not in (None, ''): + select_value = candidate + break + if isinstance(select_value, dict): + select_value = select_value.get('value') or select_value.get('label') or select_value.get('index') + if select_value not in (None, ''): + if isinstance(select_value, str): + select_value = select_value.strip() + result['select'] = select_value + + return result + + +class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + bot: DingTalkClient + bot_account_id: str + message_converter: DingTalkMessageConverter = DingTalkMessageConverter() + event_converter: DingTalkEventConverter = DingTalkEventConverter() + config: dict + card_instance_id_dict: ( + dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片 + ) + # outTrackId → form snapshot {session_key, launcher_type, launcher_id, form_token, + # workflow_run_id, actions, node_title, form_content, expires_at, open_space_id, + # user_id_hint, current_text}. Lookup keys for the data-source pull endpoint and + # the STREAM card-action callback. + card_state: dict + # session_key → out_track_id of the currently-active card for the + # conversation turn. Lets resumed-workflow chunks (which arrive on a + # synthetic event with a fresh resp_message_id) keep updating the same + # card the user clicked instead of getting a new one. + active_turn_card: dict + # session_key → accumulated streaming text for the active turn. Read + # by _paint_form_on_card so the post-pause form keeps the streamed + # context above the new prompt. + active_turn_text: dict + # event_type → callback. The abstract base class doesn't declare this, + # so we must do it here or pydantic silently drops `listeners={}` in + # super().__init__ and any access raises AttributeError. + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] + ap: typing.Any = None + bot_uuid: str = '' + # DingTalk media_id (`@xxx` format) for the bot avatar image, fetched + # on adapter startup by uploading the bundled LangBot logo via the + # legacy /media/upload endpoint. Empty string when the upload hasn't + # run yet or failed — the template's Avatar then falls back to its + # default (initials of `name`). + bot_avatar_media_id: str = '' + + # Path to the LangBot logo bundled in the repo (`res/logo-blue.png`), + # resolved relative to this file. Updated to find the file even when + # LangBot is installed as a package or run from a different cwd. + _LOGO_PATH: typing.ClassVar[pathlib.Path] = pathlib.Path(__file__).resolve().parents[5] / 'res' / 'logo-blue.png' + + def __init__(self, config: dict, logger: EventLogger): + required_keys = [ + 'client_id', + 'client_secret', + 'robot_name', + 'robot_code', + ] + missing_keys = [key for key in required_keys if key not in config] + if missing_keys: + raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员') + bot = DingTalkClient( + client_id=config['client_id'], + client_secret=config['client_secret'], + robot_name=config['robot_name'], + robot_code=config['robot_code'], + markdown_card=config['markdown_card'], + logger=logger, + ) + bot_account_id = config['robot_name'] + super().__init__( + config=config, + logger=logger, + card_instance_id_dict={}, + card_state={}, + active_turn_card={}, + active_turn_text={}, + bot_account_id=bot_account_id, + bot=bot, + listeners={}, + ) + # Wire the card-action callback after super().__init__ so we can reference + # self.* — the client's handler stores this as a soft reference and reads + # it at fire time. + self.bot.card_action_callback = self._on_card_action + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + event = await DingTalkEventConverter.yiri2target( + message_source, + ) + incoming_message = event.incoming_message + + markdown_enabled = self.config.get('markdown_card', False) + content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + await self.bot.send_message(content, incoming_message, at) + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + message_id = bot_message.resp_message_id + msg_seq = bot_message.msg_sequence + + form_template_id = (self.config.get('human_input_card_template_id') or '').strip() + form_data = getattr(bot_message, '_form_data', None) + if is_final and self.ap is not None: + self.ap.logger.info( + f'DingTalk reply_message_chunk final: form_data_present={form_data is not None}, ' + f'form_template_configured={bool(form_template_id)}' + ) + + if form_data and is_final: + await self._handle_form_chunk(message_source, bot_message, message, form_data) + return + + if (msg_seq - 1) % 8 == 0 or is_final: + markdown_enabled = self.config.get('markdown_card', False) + content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + if not content and bot_message.content: + content = bot_message.content # 兼容直接传入content的情况 + + chat_card_entry = self.card_instance_id_dict.get(message_id) + if chat_card_entry is None: + # No streaming chat card was created for this query — common + # path for synthetic events (e.g. resumed workflow after a + # button click). Lazy-create one so the resumed output streams + # into a card just like a normal conversation, instead of + # being deferred and sent in one shot on is_final. + if not content: + return # nothing to stream yet + chat_card_entry = await self._lazy_create_resume_chat_card(message_source, message_id) + if chat_card_entry is None: + # Lazy-create failed (no template configured); fall back + # to a one-shot proactive message on the final chunk. + if is_final: + await self._send_proactive_to_event(message_source, content) + return + + card_instance, card_instance_id = chat_card_entry + # btns is reserved exclusively for Dify form-action buttons. + # The template renders an Avatar header above the markdown + # content; no feedback buttons get injected here. + if content: + if form_template_id: + # The card content has already been written via + # update_card_data (in _paint_form_on_card and the + # initial card creation). The streaming endpoint + # (PUT /v1.0/card/streaming) does not propagate + # updates on cards whose content was last set via + # update_card_data — they take different code paths + # on the DingTalk client. Stick with update_card_data + # for the whole turn for consistency. + try: + await self.bot.update_card_data( + out_track_id=card_instance_id, + card_param_map=self._card_params( + content=content, + btns='[]', + flowStatus='3' if is_final else '1', + **_dingtalk_empty_form_component_params(), + ), + ) + session_key = self._session_key_from_event(message_source) + if session_key: + self.active_turn_text[session_key] = content + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: update card content failed') + else: + await self.bot.send_card_message(card_instance, card_instance_id, content, is_final) + if is_final: + if form_template_id and not content: + # Empty final chunk still needs to leave the card with + # flowStatus=3 so the spinner stops. + try: + await self.bot.update_card_data( + out_track_id=card_instance_id, + card_param_map=self._card_params( + flowStatus='3', + **_dingtalk_empty_form_component_params(), + ), + ) + except Exception: + pass + if bot_message.tool_calls is None: + self.card_instance_id_dict.pop(message_id, None) + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + markdown_enabled = self.config.get('markdown_card', False) + content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + if target_type == 'person': + await self.bot.send_proactive_message_to_one(target_id, content) + if target_type == 'group': + await self.bot.send_proactive_message_to_group(target_id, content) + + async def is_stream_output_supported(self) -> bool: + is_stream = False + if self.config.get('enable-stream-reply', None): + is_stream = True + return is_stream + + async def create_message_card(self, message_id, event): + form_template_id = (self.config.get('human_input_card_template_id') or '').strip() + legacy_template_id = self.config.get('card_template_id', '') + + # Synthetic events (button clicks): look up the card already in + # active_turn_card so reply_message_chunk can stream to it. + if event is None or event.source_platform_object is None: + if form_template_id: + session_key = self._session_key_from_event(event) if event is not None else '' + carry = self.active_turn_card.get(session_key, '') if session_key else '' + if carry: + self.card_instance_id_dict[message_id] = (None, carry) + return True + return False + + if form_template_id: + # Create one card with the form template, empty buttons, + # pending state. Streaming writes content to it; form pause + # paints buttons onto it. One card per turn, no duplication. + incoming_message = event.source_platform_object.incoming_message + out_track_id = uuid.uuid4().hex + is_group = str(incoming_message.conversation_type) == '2' + if is_group: + open_space_id = f'dtv1.card//IM_GROUP.{incoming_message.conversation_id}' + else: + open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}' + try: + await self.bot.create_and_deliver_card( + card_template_id=form_template_id, + out_track_id=out_track_id, + open_space_id=open_space_id, + is_group=is_group, + card_param_map=self._card_params( + content='', + btns='[]', + flowStatus='1', + **_dingtalk_empty_form_component_params(), + ), + callback_type='STREAM', + ) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: create form-template card failed') + return False + self.card_instance_id_dict[message_id] = (None, out_track_id) + session_key = self._session_key_from_event(event) + if session_key: + self.active_turn_card[session_key] = out_track_id + self.active_turn_text[session_key] = '' + return True + + # Legacy chat-card path (no form template). + incoming_message = event.source_platform_object.incoming_message + card_auto_layout = self.config.get('card_auto_layout', False) + card_instance, card_instance_id = await self.bot.create_and_card( + legacy_template_id, incoming_message, card_auto_layout=card_auto_layout + ) + self.card_instance_id_dict[message_id] = (card_instance, card_instance_id) + return True + + def _session_key_from_event(self, event) -> str: + """Return launcher_type_launcher_id for an event, '' if unrecoverable.""" + if event is None: + return '' + spo = event.source_platform_object + if spo is None: + try: + if isinstance(event, platform_events.GroupMessage): + return f'group_{event.group.id}' + return f'person_{event.sender.id}' + except Exception: + return '' + try: + inc = spo.incoming_message + if str(inc.conversation_type) == '2': + return f'group_{inc.conversation_id}' + return f'person_{inc.sender_staff_id}' + except Exception: + return '' + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + async def on_message(event: DingTalkEvent): + try: + return await callback( + await self.event_converter.target2yiri(event, self.config['robot_name']), + self, + ) + except Exception: + await self.logger.error(f'Error in dingtalk callback: {traceback.format_exc()}') + + if event_type == platform_events.FriendMessage: + self.bot.on_message('FriendMessage')(on_message) + elif event_type == platform_events.GroupMessage: + self.bot.on_message('GroupMessage')(on_message) + + async def run_async(self): + # Upload the bundled LangBot logo so the card Avatar can render + # via DingTalk's media CDN — external URLs (e.g. raw.githubusercontent) + # are blocked by DingTalk's Avatar.imageUrl resolver. Non-fatal if + # the upload fails: cards still render without an avatar image. + if self._LOGO_PATH.exists(): + media_id = await self.bot.upload_image_media(str(self._LOGO_PATH)) + if media_id: + self.bot_avatar_media_id = media_id + if self.ap is not None: + self.ap.logger.info(f'DingTalk bot avatar uploaded: media_id={media_id}') + else: + if self.ap is not None: + self.ap.logger.warning('DingTalk bot avatar upload failed; card will use default') + else: + if self.ap is not None: + self.ap.logger.warning(f'DingTalk bot avatar source not found: {self._LOGO_PATH}') + await self.bot.start() + + def _card_params(self, **extra) -> dict: + """Build a cardParamMap dict that always carries `bot_avatar` + (when uploaded) alongside whatever caller-specific params. The + bot_avatar key gets dropped on every update_card_data call — + DingTalk wipes unspecified template variables, so re-sending it + on each update is mandatory.""" + params = {} + if self.bot_avatar_media_id: + params['bot_avatar'] = self.bot_avatar_media_id + params.update(extra) + return params + + async def kill(self) -> bool: + await self.bot.stop() + return True + + async def is_muted(self) -> bool: + return False + + async def unregister_listener( + self, + event_type: type, + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + return super().unregister_listener(event_type, callback) + + # ------------------------------------------------------------------ + # Dify human-input form support + # ------------------------------------------------------------------ + + def set_bot_uuid(self, bot_uuid: str): + """Receive the bot uuid from the platform manager. + + Used to compose the public-facing unified-webhook URL for the card + dynamic-data-source pull endpoint. + """ + self.bot_uuid = bot_uuid + + def _derive_open_space(self, message_source: platform_events.MessageEvent) -> tuple[str, bool]: + """Return (openSpaceId, is_group) for the given inbound event.""" + if isinstance(message_source, platform_events.GroupMessage): + return f'dtv1.card//IM_GROUP.{message_source.group.id}', True + return f'dtv1.card//IM_ROBOT.{message_source.sender.id}', False + + def _derive_session_descriptor( + self, message_source: platform_events.MessageEvent + ) -> tuple[provider_session.LauncherTypes, str, str]: + """Return (launcher_type, launcher_id, sender_user_id) for routing.""" + if isinstance(message_source, platform_events.GroupMessage): + return ( + provider_session.LauncherTypes.GROUP, + str(message_source.group.id), + str(message_source.sender.id), + ) + return ( + provider_session.LauncherTypes.PERSON, + str(message_source.sender.id), + str(message_source.sender.id), + ) + + async def _handle_form_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + form_data: dict, + ) -> None: + """Surface human-input prompt + buttons on the active card. + + In single-card mode (form_template_id configured): update the + EXISTING card with form buttons so it transitions from streaming + output to prompt+buttons on the same card. In legacy mode: + finalize the chat card and deliver a separate form card. + """ + if self.ap is not None: + self.ap.logger.info( + f'DingTalk _handle_form_chunk: actions={len(form_data.get("actions") or [])}, ' + f'node_title={form_data.get("node_title", "")!r}' + ) + message_id = bot_message.resp_message_id + template_id = (self.config.get('human_input_card_template_id') or '').strip() + + if template_id: + # Single-card mode: paint prompt + buttons onto the existing card. + session_key = self._session_key_from_event(message_source) + entry = self.card_instance_id_dict.get(message_id) + out_track_id = entry[1] if entry else None + if not out_track_id and session_key: + out_track_id = self.active_turn_card.get(session_key, '') + if out_track_id: + await self._paint_form_on_card(message_source, out_track_id, form_data, session_key) + self.card_instance_id_dict.pop(message_id, None) + return + + # No existing card (e.g. Dify paused immediately with no LLM + # output before the pause). Create a form card directly. + await self._send_form_card(message_source, form_data, template_id) + self.card_instance_id_dict.pop(message_id, None) + return + + # Legacy mode: finalize the streaming card with text fallback. + chat_card_entry = self.card_instance_id_dict.pop(message_id, None) + if chat_card_entry is not None: + _, chat_out_track_id = chat_card_entry + markdown_enabled = self.config.get('markdown_card', False) + text_content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + if not text_content and bot_message.content: + text_content = bot_message.content + try: + await self.bot.send_card_message(None, chat_out_track_id, text_content or '​', True) + except Exception: + await self.logger.error(f'DingTalk: finalize chat card before form failed: {traceback.format_exc()}') + + await self.send_message_text_form(message_source, form_data) + + async def _paint_form_on_card( + self, + message_source: platform_events.MessageEvent, + out_track_id: str, + form_data: dict, + session_key: str, + ) -> None: + """Update an existing card's content + buttons for human-input.""" + actions = list(form_data.get('actions') or []) + node_title = form_data.get('node_title', '') or 'Human Input Required' + form_content = _dingtalk_clean_form_content(form_data) + should_show_actions = not _dingtalk_pending_input_defs(form_data) + component_params = _dingtalk_form_component_params(form_data) + native_field = _dingtalk_supports_native_field(form_data) + if self.ap is not None and component_params.get('select_visible'): + self.ap.logger.info( + f'DingTalk form select params: field={form_data.get("_current_input_field", "")!r} ' + f'options={len(component_params.get("select_options") or [])}' + ) + + # Record form state for the click-handler. + launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source) + self.card_state[out_track_id] = { + 'session_key': session_key, + 'launcher_type': launcher_type.value, + 'launcher_id': launcher_id, + 'sender_user_id': sender_user_id, + 'form_token': form_data.get('form_token', ''), + 'workflow_run_id': form_data.get('workflow_run_id', ''), + 'pipeline_uuid': form_data.get('pipeline_uuid', ''), + 'actions': actions if should_show_actions else [], + 'all_actions': actions, + 'node_title': node_title, + 'form_content': form_content, + 'current_input_field': str(form_data.get('_current_input_field') or ''), + 'input_defs': _dingtalk_form_input_defs(form_data), + 'inputs': form_data.get('inputs') or {}, + } + + btns = self._build_btns(actions if should_show_actions else [], out_track_id) + parts: list[str] = [] + prior = self.active_turn_text.get(session_key, '') if session_key else '' + if prior.strip(): + parts.append(prior.rstrip()) + parts.append('
') + # DingTalk's card markdown widget strips `\n\n` paragraph breaks in + # template content slots, fusing inline siblings into a single line. + # Force visual line breaks with explicit HTML `
` tags so the + # title sits on its own line above form_content. + if node_title: + parts.append(f'**{node_title}**') + if form_content: + parts.append(_dingtalk_card_markdown(form_content)) + missing_completed_lines = _dingtalk_missing_completed_input_lines(form_data, form_content) + if missing_completed_lines: + parts.append('
' + '
'.join(missing_completed_lines)) + input_hint_lines = [] if native_field else _dingtalk_input_hint_lines(form_data) + if input_hint_lines: + parts.append('Fill these fields in chat before choosing an action:
' + '
'.join(input_hint_lines)) + display_content = '

'.join(parts) + + try: + await self.bot.update_card_data( + out_track_id=out_track_id, + card_param_map=self._card_params( + content=display_content, + btns=json.dumps(btns, ensure_ascii=False), + flowStatus='3', + **component_params, + ), + ) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: paint form on card failed') + await self.send_message_text_form(message_source, form_data) + return + + @staticmethod + def _build_btns(actions: list, out_track_id: str) -> list: + btns = [] + for idx, action in enumerate(actions): + action_id = str(action.get('id') or '') + title = str(action.get('title') or action_id or f'选项 {idx + 1}') + style = (action.get('button_style') or '').lower() + if style == 'primary' or (style == '' and idx == 0): + color = 'blue' + elif style == 'danger': + color = 'red' + else: + color = 'gray' + btns.append( + { + 'text': title, + 'color': color, + 'status': 'normal', + 'event': { + 'type': 'sendCardRequest', + 'params': { + 'actionId': action_id, + 'params': {'action_id': action_id, 'out_track_id': out_track_id}, + }, + }, + } + ) + return btns + + async def _send_form_card( + self, + message_source: platform_events.MessageEvent, + form_data: dict, + template_id: str, + ) -> None: + """Deliver a new card pre-loaded with the human-input prompt + buttons.""" + out_track_id = uuid.uuid4().hex + open_space_id, is_group = self._derive_open_space(message_source) + launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source) + session_key = f'{launcher_type.value}_{launcher_id}' + + actions = list(form_data.get('actions') or []) + node_title = form_data.get('node_title', '') or 'Human Input Required' + form_content = _dingtalk_clean_form_content(form_data) + should_show_actions = not _dingtalk_pending_input_defs(form_data) + component_params = _dingtalk_form_component_params(form_data) + native_field = _dingtalk_supports_native_field(form_data) + if self.ap is not None and component_params.get('select_visible'): + self.ap.logger.info( + f'DingTalk form select params: field={form_data.get("_current_input_field", "")!r} ' + f'options={len(component_params.get("select_options") or [])}' + ) + + self.card_state[out_track_id] = { + 'session_key': session_key, + 'launcher_type': launcher_type.value, + 'launcher_id': launcher_id, + 'sender_user_id': sender_user_id, + 'form_token': form_data.get('form_token', ''), + 'workflow_run_id': form_data.get('workflow_run_id', ''), + 'pipeline_uuid': form_data.get('pipeline_uuid', ''), + 'actions': actions if should_show_actions else [], + 'all_actions': actions, + 'node_title': node_title, + 'form_content': form_content, + 'current_input_field': str(form_data.get('_current_input_field') or ''), + 'input_defs': _dingtalk_form_input_defs(form_data), + 'inputs': form_data.get('inputs') or {}, + 'open_space_id': open_space_id, + 'is_group': is_group, + } + + parts = [] + if node_title: + parts.append(f'**{node_title}**') + if form_content: + parts.append(_dingtalk_card_markdown(form_content)) + missing_completed_lines = _dingtalk_missing_completed_input_lines(form_data, form_content) + if missing_completed_lines: + parts.append('
' + '
'.join(missing_completed_lines)) + input_hint_lines = [] if native_field else _dingtalk_input_hint_lines(form_data) + if input_hint_lines: + parts.append('Fill these fields in chat before choosing an action:
' + '
'.join(input_hint_lines)) + display_content = '

'.join(parts) + + btns = self._build_btns(actions if should_show_actions else [], out_track_id) + + try: + if self.ap is not None: + self.ap.logger.info( + f'DingTalk _send_form_card: out_track_id={out_track_id} template_id={template_id} ' + f'open_space_id={open_space_id} is_group={is_group} btns={len(btns)}' + ) + await self.bot.create_and_deliver_card( + card_template_id=template_id, + out_track_id=out_track_id, + open_space_id=open_space_id, + is_group=is_group, + card_param_map=self._card_params( + content=display_content, + btns=json.dumps(btns, ensure_ascii=False), + flowStatus='3', + **component_params, + ), + callback_type='STREAM', + ) + except Exception: + await self.logger.error(f'DingTalk: deliver form card failed: {traceback.format_exc()}') + await self.send_message_text_form(message_source, form_data) + self.card_state.pop(out_track_id, None) + + async def _lazy_create_resume_chat_card( + self, + message_source: platform_events.MessageEvent, + message_id: str, + ) -> typing.Optional[tuple]: + """Create a new card for resumed-workflow streaming output. + + Used after a button click triggers a synthetic event — the form + card stays put with the selection notice, and a fresh card is + spawned here for the LLM reply to stream into. + """ + form_template_id = (self.config.get('human_input_card_template_id') or '').strip() + legacy_template_id = (self.config.get('card_template_id') or '').strip() + template_id = form_template_id or legacy_template_id + if not template_id: + return None + out_track_id = uuid.uuid4().hex + open_space_id, is_group = self._derive_open_space(message_source) + if form_template_id: + card_param_map = self._card_params( + content='', + btns='[]', + flowStatus='1', + **_dingtalk_empty_form_component_params(), + ) + card_data_config = None + else: + # Legacy chat-card template doesn't carry a `bot_avatar` + # variable, so don't decorate the param map here. + card_param_map = {'content': '', 'query': '...'} + card_data_config = {'autoLayout': self.config.get('card_auto_layout', False)} + try: + success = await self.bot.create_and_deliver_card( + card_template_id=template_id, + out_track_id=out_track_id, + open_space_id=open_space_id, + is_group=is_group, + card_param_map=card_param_map, + card_data_config=card_data_config, + callback_type='STREAM', + ) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: lazy create resume chat card failed') + return None + if not success: + return None + entry = (None, out_track_id) + self.card_instance_id_dict[message_id] = entry + # Register as the active card so any further chunks on this turn + # (and a subsequent re-pause) land on the same new card. + session_key = self._session_key_from_event(message_source) + if session_key: + self.active_turn_card[session_key] = out_track_id + self.active_turn_text[session_key] = '' + return entry + + async def send_message_text_form( + self, + message_source: platform_events.MessageEvent, + form_data: dict, + ) -> None: + """Fallback: send the human-input prompt as plain text.""" + if form_data.get('_current_input_field') and not form_data.get('_action_select_only'): + parts = [] + node_title = form_data.get('node_title', '') + if node_title: + parts.append(f'[Human Input Required] {node_title}') + form_content = form_data.get('form_content') or '' + if form_content: + parts.append(form_content) + display_text = '\n\n'.join(parts) + else: + display_text = _format_human_input_text( + form_data.get('node_title', ''), + form_data.get('form_content', ''), + form_data.get('actions', []) or [], + ) + await self._send_proactive_to_event(message_source, display_text) + + async def _send_proactive_to_event( + self, + message_source: platform_events.MessageEvent, + content: str, + ) -> None: + """Send `content` as a proactive message to the conversation behind + `message_source`. Used when no inbound chatbot message exists to + anchor a card on (e.g. resumed flows triggered by card actions). + """ + if not content: + return + if self.ap is not None: + target = ( + str(message_source.group.id) + if isinstance(message_source, platform_events.GroupMessage) + else str(message_source.sender.id) + ) + self.ap.logger.info( + f'DingTalk _send_proactive_to_event: target={target} ' + f'is_group={isinstance(message_source, platform_events.GroupMessage)} content_len={len(content)}' + ) + try: + if isinstance(message_source, platform_events.GroupMessage): + await self.bot.send_proactive_message_to_group(str(message_source.group.id), content) + else: + await self.bot.send_proactive_message_to_one(str(message_source.sender.id), content) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: send proactive message failed') + await self.logger.error(f'DingTalk: send proactive message failed: {traceback.format_exc()}') + + async def _on_card_action(self, payload: dict) -> None: + """Translate a card button click into a synthetic query. + + Reads the clicked button's ``actionId`` (the real Dify action id — + the ButtonGroup template sends it back via `event.params.actionId`), + recovers the action title from ``card_state``, and enqueues a + synthetic `_dify_form_action` query the same way Lark / Telegram do. + """ + if self.ap is not None: + self.ap.logger.info( + f'DingTalk _on_card_action received: out_track_id={payload.get("out_track_id")} ' + f'payload_action_id={payload.get("action_id")!r} params={payload.get("params")!r}' + ) + out_track_id = payload.get('out_track_id') or '' + params = payload.get('params') or {} + # ButtonGroup `sendCardRequest` events surface the click id at the + # callback top level as `actionId`; fall back to `params.action_id` + # (alternate template wiring) and `params.actionId`. + raw_action_id = ( + (payload.get('action_id') or '').strip() + or (params.get('action_id') or '').strip() + or (params.get('actionId') or '').strip() + or (params.get('id') or '').strip() + ) + state = self.card_state.get(out_track_id) + if state is None: + await self.logger.warning(f'DingTalk: card action received for unknown out_track_id={out_track_id}') + return + + actions = state.get('actions', []) or [] + known_action_ids = {str(action.get('id', '')) for action in actions} + component_inputs = _dingtalk_extract_component_inputs(params) + if component_inputs and (not raw_action_id or raw_action_id not in known_action_ids): + await self._enqueue_card_form_progress(payload, state, component_inputs) + return + if not raw_action_id: + await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}') + return + if raw_action_id not in known_action_ids: + await self.logger.warning( + f'DingTalk: card action_id={raw_action_id!r} is not present on out_track_id={out_track_id}' + ) + return + + action_id = raw_action_id + action_title = raw_action_id + for action in actions: + if str(action.get('id', '')) == raw_action_id: + action_title = action.get('title') or raw_action_id + break + + launcher_type = ( + provider_session.LauncherTypes.GROUP + if state.get('launcher_type') == provider_session.LauncherTypes.GROUP.value + else provider_session.LauncherTypes.PERSON + ) + launcher_id = state.get('launcher_id', '') + initiator_user_id = str(state.get('sender_user_id') or '') + actor_user_id = str(payload.get('user_id') or initiator_user_id or launcher_id) + if ( + launcher_type == provider_session.LauncherTypes.PERSON + and initiator_user_id + and actor_user_id != initiator_user_id + ): + await self.logger.warning( + f'DingTalk: user {actor_user_id} cannot act on private form created for {initiator_user_id}' + ) + return + + form_action_data = { + 'form_token': state.get('form_token', ''), + 'workflow_run_id': state.get('workflow_run_id', ''), + 'action_id': action_id, + 'action_title': action_title, + 'node_title': state.get('node_title', ''), + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': {}, + } + + message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')]) + + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=actor_user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=int(datetime.datetime.now().timestamp()), + source_platform_object=None, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=actor_user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=int(datetime.datetime.now().timestamp()), + source_platform_object=None, + ) + + bot_uuid = '' + pipeline_uuid = state.get('pipeline_uuid') or None + if self.ap is not None: + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + try: + self.ap.logger.info( + f'DingTalk _on_card_action enqueuing form action: action_id={action_id!r} ' + f'action_title={action_title!r} launcher_type={launcher_type.value} ' + f'launcher_id={launcher_id} actor_user_id={actor_user_id} ' + f'bot_uuid={bot_uuid} pipeline_uuid={pipeline_uuid}' + ) + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=actor_user_id, + message_event=synthetic_event, + message_chain=message_chain, + adapter=self, + pipeline_uuid=pipeline_uuid, + variables={ + '_dify_form_action': form_action_data, + '_routed_by_rule': True, + }, + ) + self.ap.logger.info('DingTalk _on_card_action: query enqueued OK') + except Exception: + self.ap.logger.exception('DingTalk: enqueue form action query failed') + return + + # Visual feedback on the form card itself: keep the prompt visible, + # add a selection line, remove the buttons. The resumed-workflow + # output lives on a separate new card (lazy-created in + # reply_message_chunk on the synthetic event), so the form card + # stays put as a record of the user's selection. + asyncio.create_task( + self._mark_card_resolved( + out_track_id, + action_title, + node_title=state.get('node_title', ''), + form_content=state.get('form_content', ''), + input_defs=state.get('input_defs') or [], + inputs=state.get('inputs') or {}, + ) + ) + + # Crucial: do NOT leave the form card's out_track_id in + # active_turn_card — otherwise create_message_card for the + # synthetic event would reuse it for the resume output, painting + # the LLM reply on top of the selection notice. Clear it so the + # resume goes through the lazy-create path and spawns a fresh card. + session_key = state.get('session_key', '') + if session_key and self.active_turn_card.get(session_key) == out_track_id: + self.active_turn_card.pop(session_key, None) + self.active_turn_text.pop(session_key, None) + + # Once consumed, drop the state — the runner clears _PENDING_FORMS too. + self.card_state.pop(out_track_id, None) + + async def _enqueue_card_form_progress( + self, + payload: dict, + state: dict, + component_inputs: dict, + ) -> None: + out_track_id = payload.get('out_track_id') or '' + launcher_type = ( + provider_session.LauncherTypes.GROUP + if state.get('launcher_type') == provider_session.LauncherTypes.GROUP.value + else provider_session.LauncherTypes.PERSON + ) + launcher_id = state.get('launcher_id', '') + initiator_user_id = str(state.get('sender_user_id') or '') + actor_user_id = str(payload.get('user_id') or initiator_user_id or launcher_id) + if ( + launcher_type == provider_session.LauncherTypes.PERSON + and initiator_user_id + and actor_user_id != initiator_user_id + ): + await self.logger.warning( + f'DingTalk: user {actor_user_id} cannot update private form created for {initiator_user_id}' + ) + return + form_action_data = { + 'form_token': state.get('form_token', ''), + 'workflow_run_id': state.get('workflow_run_id', ''), + 'action_id': '', + 'action_title': '', + 'node_title': state.get('node_title', ''), + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': component_inputs, + '_current_input_field': state.get('current_input_field', ''), + '_input_progress': True, + } + message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Input]')]) + + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=actor_user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=int(datetime.datetime.now().timestamp()), + source_platform_object=None, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=actor_user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=int(datetime.datetime.now().timestamp()), + source_platform_object=None, + ) + + if self.ap is None: + return + + bot_uuid = '' + pipeline_uuid = state.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + try: + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=actor_user_id, + message_event=synthetic_event, + message_chain=message_chain, + adapter=self, + pipeline_uuid=pipeline_uuid, + variables={ + '_dify_form_action': form_action_data, + '_routed_by_rule': True, + }, + ) + self.ap.logger.info( + f'DingTalk card form input enqueued: out_track_id={out_track_id} ' + f'field={state.get("current_input_field", "")!r}' + ) + except Exception: + self.ap.logger.exception('DingTalk: enqueue form input query failed') + + async def _mark_card_resolved( + self, + out_track_id: str, + action_title: str, + *, + node_title: str = '', + form_content: str = '', + input_defs: list | None = None, + inputs: dict | None = None, + ) -> None: + """Update the form card to acknowledge the user's selection. + + Keeps the original prompt visible, adds a selection notice, and + clears the buttons. The card stays as a permanent record of the + choice; the resumed workflow's output goes to a separate new card. + """ + parts: list[str] = [] + if node_title: + parts.append(f'**{node_title}**') + if form_content: + parts.append(_dingtalk_card_markdown(form_content)) + missing_completed_lines = _dingtalk_missing_completed_input_lines( + { + 'input_defs': input_defs or [], + 'inputs': inputs or {}, + }, + form_content, + ) + if missing_completed_lines: + parts.append('
' + '
'.join(missing_completed_lines)) + parts.append(f'
✅ {action_title}') + content = '

'.join(parts) + if self.ap is not None: + self.ap.logger.info(f'DingTalk _mark_card_resolved: out_track_id={out_track_id} action={action_title!r}') + try: + await self.bot.update_card_data( + out_track_id=out_track_id, + card_param_map=self._card_params( + content=content, + btns='[]', + flowStatus='3', + **_dingtalk_empty_form_component_params(), + ), + ) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: mark card resolved failed') diff --git a/src/langbot/pkg/platform/sources/dingtalk.svg b/src/langbot/pkg/platform/sources/dingtalk.svg new file mode 100644 index 0000000..b60653b --- /dev/null +++ b/src/langbot/pkg/platform/sources/dingtalk.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/langbot/pkg/platform/sources/dingtalk.yaml b/src/langbot/pkg/platform/sources/dingtalk.yaml new file mode 100644 index 0000000..261a8bf --- /dev/null +++ b/src/langbot/pkg/platform/sources/dingtalk.yaml @@ -0,0 +1,144 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: dingtalk + label: + en_US: DingTalk + zh_Hans: 钉钉 + zh_Hant: 釘釘 + description: + en_US: DingTalk Adapter + zh_Hans: 钉钉适配器,请查看文档了解使用方式 + zh_Hant: 釘釘適配器,請查看文件了解使用方式 + icon: dingtalk.svg +spec: + categories: + - china + help_links: + zh: https://link.langbot.app/zh/platforms/dingtalk + en: https://link.langbot.app/en/platforms/dingtalk + ja: https://link.langbot.app/ja/platforms/dingtalk + config: + - name: one-click-create + label: + en_US: One-Click Create App + zh_Hans: 一键创建应用 + zh_Hant: 一鍵建立應用 + description: + en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually." + zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。" + zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。" + type: qr-code-login + login_platform: dingtalk + required: false + - name: client_id + label: + en_US: Client ID + zh_Hans: 客户端ID + zh_Hant: 用戶端ID + type: string + required: true + default: "" + - name: client_secret + label: + en_US: Client Secret + zh_Hans: 客户端密钥 + zh_Hant: 用戶端密鑰 + type: string + required: true + default: "" + - name: robot_code + label: + en_US: Robot Code + zh_Hans: 机器人代码 + zh_Hant: 機器人代碼 + description: + en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration." + zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。" + zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。" + type: string + required: true + default: "" + - name: robot_name + label: + en_US: Robot Name + zh_Hans: 机器人名称 + zh_Hant: 機器人名稱 + type: string + required: true + default: "" + - name: markdown_card + label: + en_US: Markdown Card + zh_Hans: 是否使用 Markdown 卡片 + zh_Hant: 是否使用 Markdown 卡片 + type: boolean + required: false + default: true + - name: enable-stream-reply + label: + en_US: Enable Stream Reply Mode + zh_Hans: 启用钉钉卡片流式回复模式 + zh_Hant: 啟用釘釘卡片串流回覆模式 + description: + en_US: If enabled, the bot will use the stream of lark reply mode + zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容 + zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容 + type: boolean + required: true + default: false + - name: card_auto_layout + label: + en_US: Card Auto Layout + zh_Hans: 卡片宽屏自动布局 + zh_Hant: 卡片寬螢幕自動佈局 + type: boolean + required: false + default: false + - name: card_template_id + label: + en_US: card template id + zh_Hans: 卡片模板ID + zh_Hant: 卡片範本ID + type: string + required: true + default: "填写你的卡片template_id" + - name: human_input_card_template_download + label: + en_US: Download Human Input Card Template + zh_Hans: 下载人工输入卡片模板 + zh_Hant: 下載人工輸入卡片範本 + description: + en_US: "Used as the only card template ID for the whole conversation turn. Download the built-in template, then import the JSON in DingTalk Open Platform > Card Platform / Card Template Management. After DingTalk creates the template, copy its template ID into the field below. The template already wires `content` (MarkdownBlock) and `btns` (ButtonGroup). Leave empty to fall back to the legacy two-card behavior." + zh_Hans: "用作整个对话回合唯一卡片的模板 ID。先下载内置模板,再到钉钉开放平台 > 卡片平台 / 卡片模板管理中导入该 JSON;钉钉生成模板后,将模板 ID 填到这里。模板已预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为。" + zh_Hant: "用作整個對話回合唯一卡片的範本 ID。先下載內建範本,再到釘釘開放平台 > 卡片平台 / 卡片範本管理中匯入該 JSON;釘釘產生範本後,將範本 ID 填到這裡。範本已預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為。" + type: download-link + required: false + default: "" + url: /api/v1/platform/adapters/dingtalk/human-input-card-template + download_filename: dingtalk_human_input_card.json + help_links: + zh: https://open-dev.dingtalk.com/fe/card + en: https://open-dev.dingtalk.com/fe/card + ja: https://open-dev.dingtalk.com/fe/card + help_label: + en_US: Import Guide + zh_Hans: 导入指引 + zh_Hant: 匯入指引 + ja_JP: インポート手順 + - name: human_input_card_template_id + label: + en_US: Human Input Card Template ID + zh_Hans: 人工输入卡片模板ID + zh_Hant: 人工輸入卡片範本ID + description: + en_US: "Paste the template ID generated after importing the human input card template." + zh_Hans: "填写导入人工输入卡片模板后生成的模板 ID。" + zh_Hant: "填寫匯入人工輸入卡片範本後產生的範本 ID。" + type: string + required: false + default: "" +execution: + python: + path: ./dingtalk.py + attr: DingTalkAdapter diff --git a/src/langbot/pkg/platform/sources/discord.py b/src/langbot/pkg/platform/sources/discord.py new file mode 100644 index 0000000..fae65f6 --- /dev/null +++ b/src/langbot/pkg/platform/sources/discord.py @@ -0,0 +1,1657 @@ +from __future__ import annotations + +import discord +from discord import ui as discord_ui + +import typing +import re +import base64 +import uuid +import os +import datetime +import time +import traceback + +# 使用BytesIO创建文件对象,避免路径问题 +import io +import asyncio +from enum import Enum + +from langbot.pkg.utils import httpclient +import pydantic + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from ..logger import EventLogger + + +# 语音功能相关异常定义 +class VoiceConnectionError(Exception): + """语音连接基础异常""" + + def __init__(self, message: str, error_code: str = None, guild_id: int = None): + super().__init__(message) + self.error_code = error_code + self.guild_id = guild_id + self.timestamp = datetime.datetime.now() + + +class VoicePermissionError(VoiceConnectionError): + """语音权限异常""" + + def __init__(self, message: str, missing_permissions: list = None, user_id: int = None, channel_id: int = None): + super().__init__(message, 'PERMISSION_ERROR') + self.missing_permissions = missing_permissions or [] + self.user_id = user_id + self.channel_id = channel_id + + +class VoiceNetworkError(VoiceConnectionError): + """语音网络异常""" + + def __init__(self, message: str, retry_count: int = 0): + super().__init__(message, 'NETWORK_ERROR') + self.retry_count = retry_count + self.last_attempt = datetime.datetime.now() + + +class VoiceConnectionStatus(Enum): + """语音连接状态枚举""" + + IDLE = 'idle' + CONNECTING = 'connecting' + CONNECTED = 'connected' + PLAYING = 'playing' + RECONNECTING = 'reconnecting' + FAILED = 'failed' + + +class VoiceConnectionInfo: + """ + 语音连接信息类 + + 用于存储和管理单个语音连接的详细信息,包括连接状态、时间戳、 + 频道信息等。提供连接信息的标准化数据结构。 + + @author: @ydzat + @version: 1.0 + @since: 2025-07-04 + """ + + def __init__(self, guild_id: int, channel_id: int, channel_name: str = None): + """ + 初始化语音连接信息 + + @author: @ydzat + + Args: + guild_id (int): 服务器ID + channel_id (int): 语音频道ID + channel_name (str, optional): 语音频道名称 + """ + self.guild_id = guild_id + self.channel_id = channel_id + self.channel_name = channel_name or f'Channel-{channel_id}' + self.connected = False + self.connection_time: datetime.datetime = None + self.last_activity = datetime.datetime.now() + self.status = VoiceConnectionStatus.IDLE + self.user_count = 0 + self.latency = 0.0 + self.connection_health = 'unknown' + self.voice_client = None + + def update_status(self, status: VoiceConnectionStatus): + """ + 更新连接状态 + + @author: @ydzat + + Args: + status (VoiceConnectionStatus): 新的连接状态 + """ + self.status = status + self.last_activity = datetime.datetime.now() + + if status == VoiceConnectionStatus.CONNECTED: + self.connected = True + if self.connection_time is None: + self.connection_time = datetime.datetime.now() + elif status in [VoiceConnectionStatus.IDLE, VoiceConnectionStatus.FAILED]: + self.connected = False + self.connection_time = None + self.voice_client = None + + def to_dict(self) -> dict: + """ + 转换为字典格式 + + @author: @ydzat + + Returns: + dict: 连接信息的字典表示 + """ + return { + 'guild_id': self.guild_id, + 'channel_id': self.channel_id, + 'channel_name': self.channel_name, + 'connected': self.connected, + 'connection_time': self.connection_time.isoformat() if self.connection_time else None, + 'last_activity': self.last_activity.isoformat(), + 'status': self.status.value, + 'user_count': self.user_count, + 'latency': self.latency, + 'connection_health': self.connection_health, + } + + +class VoiceConnectionManager: + """ + 语音连接管理器 + + 负责管理多个服务器的语音连接,提供连接建立、断开、状态查询等功能。 + 采用单例模式确保全局只有一个连接管理器实例。 + + @author: @ydzat + @version: 1.0 + @since: 2025-07-04 + """ + + def __init__(self, bot: discord.Client, logger: EventLogger): + """ + 初始化语音连接管理器 + + @author: @ydzat + + Args: + bot (discord.Client): Discord 客户端实例 + logger (EventLogger): 事件日志记录器 + """ + self.bot = bot + self.logger = logger + self.connections: typing.Dict[int, VoiceConnectionInfo] = {} + self._connection_lock = asyncio.Lock() + self._cleanup_task = None + self._monitoring_enabled = True + + async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient: + """ + 加入语音频道 + + 验证用户权限和频道状态后,建立到指定语音频道的连接。 + 支持连接复用和自动重连机制。 + + @author: @ydzat + + Args: + guild_id (int): 服务器ID + channel_id (int): 语音频道ID + user_id (int, optional): 请求用户ID,用于权限验证 + + Returns: + discord.VoiceClient: 语音客户端实例 + + Raises: + VoicePermissionError: 权限不足时抛出 + VoiceNetworkError: 网络连接失败时抛出 + VoiceConnectionError: 其他连接错误时抛出 + """ + async with self._connection_lock: + try: + # 获取服务器和频道对象 + guild = self.bot.get_guild(guild_id) + if not guild: + raise VoiceConnectionError(f'无法找到服务器 {guild_id}', 'GUILD_NOT_FOUND', guild_id) + + channel = guild.get_channel(channel_id) + if not channel or not isinstance(channel, discord.VoiceChannel): + raise VoiceConnectionError(f'无法找到语音频道 {channel_id}', 'CHANNEL_NOT_FOUND', guild_id) + + # 验证用户是否在语音频道中(如果提供了用户ID) + if user_id: + await self._validate_user_in_channel(guild, channel, user_id) + + # 验证机器人权限 + await self._validate_bot_permissions(channel) + + # 检查是否已有连接 + if guild_id in self.connections: + existing_conn = self.connections[guild_id] + if existing_conn.connected and existing_conn.voice_client: + if existing_conn.channel_id == channel_id: + # 已连接到相同频道,返回现有连接 + await self.logger.info(f'复用现有语音连接: {guild.name} -> {channel.name}') + return existing_conn.voice_client + else: + # 连接到不同频道,先断开旧连接 + await self._disconnect_internal(guild_id) + + # 建立新连接 + voice_client = await channel.connect() + + # 更新连接信息 + conn_info = VoiceConnectionInfo(guild_id, channel_id, channel.name) + conn_info.voice_client = voice_client + conn_info.update_status(VoiceConnectionStatus.CONNECTED) + conn_info.user_count = len(channel.members) + self.connections[guild_id] = conn_info + + await self.logger.info(f'成功连接到语音频道: {guild.name} -> {channel.name}') + return voice_client + + except discord.ClientException as e: + raise VoiceNetworkError(f'Discord 客户端错误: {str(e)}') + except discord.opus.OpusNotLoaded as e: + raise VoiceConnectionError(f'Opus 编码器未加载: {str(e)}', 'OPUS_NOT_LOADED', guild_id) + except Exception as e: + await self.logger.error(f'连接语音频道时发生未知错误: {str(e)}') + raise VoiceConnectionError(f'连接失败: {str(e)}', 'UNKNOWN_ERROR', guild_id) + + async def leave_voice_channel(self, guild_id: int) -> bool: + """ + 离开语音频道 + + 断开指定服务器的语音连接,清理相关资源和状态信息。 + 确保音频播放停止后再断开连接。 + + @author: @ydzat + + Args: + guild_id (int): 服务器ID + + Returns: + bool: 断开是否成功 + """ + async with self._connection_lock: + return await self._disconnect_internal(guild_id) + + async def _disconnect_internal(self, guild_id: int) -> bool: + """ + 内部断开连接方法 + + @author: @ydzat + + Args: + guild_id (int): 服务器ID + + Returns: + bool: 断开是否成功 + """ + if guild_id not in self.connections: + return True + + conn_info = self.connections[guild_id] + + try: + if conn_info.voice_client and conn_info.voice_client.is_connected(): + # 停止当前播放 + if conn_info.voice_client.is_playing(): + conn_info.voice_client.stop() + + # 等待播放完全停止 + await asyncio.sleep(0.1) + + # 断开连接 + await conn_info.voice_client.disconnect() + + conn_info.update_status(VoiceConnectionStatus.IDLE) + del self.connections[guild_id] + + await self.logger.info(f'已断开语音连接: Guild {guild_id}') + return True + + except Exception as e: + await self.logger.error(f'断开语音连接时发生错误: {str(e)}') + # 即使出错也要清理连接记录 + conn_info.update_status(VoiceConnectionStatus.FAILED) + if guild_id in self.connections: + del self.connections[guild_id] + return False + + async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]: + """ + 获取语音客户端 + + 返回指定服务器的语音客户端实例,如果未连接则返回 None。 + 会验证连接的有效性,自动清理无效连接。 + + @author: @ydzat + + Args: + guild_id (int): 服务器ID + + Returns: + Optional[discord.VoiceClient]: 语音客户端实例或 None + """ + if guild_id not in self.connections: + return None + + conn_info = self.connections[guild_id] + + # 验证连接是否仍然有效 + if conn_info.voice_client and not conn_info.voice_client.is_connected(): + # 连接已失效,清理状态 + await self._disconnect_internal(guild_id) + return None + + return conn_info.voice_client if conn_info.connected else None + + async def is_connected_to_voice(self, guild_id: int) -> bool: + """ + 检查是否连接到语音频道 + + @author: @ydzat + + Args: + guild_id (int): 服务器ID + + Returns: + bool: 是否已连接 + """ + if guild_id not in self.connections: + return False + + conn_info = self.connections[guild_id] + + # 检查实际连接状态 + if conn_info.voice_client and not conn_info.voice_client.is_connected(): + # 连接已失效,清理状态 + await self._disconnect_internal(guild_id) + return False + + return conn_info.connected + + async def get_connection_status(self, guild_id: int) -> typing.Optional[dict]: + """ + 获取连接状态信息 + + @author: @ydzat + + Args: + guild_id (int): 服务器ID + + Returns: + Optional[dict]: 连接状态信息字典或 None + """ + if guild_id not in self.connections: + return None + + conn_info = self.connections[guild_id] + + # 更新实时信息 + if conn_info.voice_client and conn_info.voice_client.is_connected(): + conn_info.latency = conn_info.voice_client.latency * 1000 # 转换为毫秒 + conn_info.connection_health = 'good' if conn_info.latency < 100 else 'poor' + + # 更新频道用户数 + guild = self.bot.get_guild(guild_id) + if guild: + channel = guild.get_channel(conn_info.channel_id) + if channel and isinstance(channel, discord.VoiceChannel): + conn_info.user_count = len(channel.members) + + return conn_info.to_dict() + + async def list_active_connections(self) -> typing.List[dict]: + """ + 列出所有活跃连接 + + @author: @ydzat + + Returns: + List[dict]: 活跃连接列表 + """ + active_connections = [] + + for guild_id, conn_info in self.connections.items(): + if conn_info.connected: + status = await self.get_connection_status(guild_id) + if status: + active_connections.append(status) + + return active_connections + + async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]: + """ + 获取语音频道信息 + + @author: @ydzat + + Args: + guild_id (int): 服务器ID + channel_id (int): 频道ID + + Returns: + Optional[dict]: 频道信息字典或 None + """ + guild = self.bot.get_guild(guild_id) + if not guild: + return None + + channel = guild.get_channel(channel_id) + if not channel or not isinstance(channel, discord.VoiceChannel): + return None + + # 获取用户信息 + users = [] + for member in channel.members: + users.append( + {'id': member.id, 'name': member.display_name, 'status': str(member.status), 'is_bot': member.bot} + ) + + # 获取权限信息 + bot_member = guild.me + permissions = channel.permissions_for(bot_member) + + return { + 'channel_id': channel_id, + 'channel_name': channel.name, + 'guild_id': guild_id, + 'guild_name': guild.name, + 'user_limit': channel.user_limit, + 'current_users': users, + 'user_count': len(users), + 'bitrate': channel.bitrate, + 'permissions': { + 'connect': permissions.connect, + 'speak': permissions.speak, + 'use_voice_activation': permissions.use_voice_activation, + 'priority_speaker': permissions.priority_speaker, + }, + } + + async def _validate_user_in_channel(self, guild: discord.Guild, channel: discord.VoiceChannel, user_id: int): + """ + 验证用户是否在语音频道中 + + @author: @ydzat + + Args: + guild: Discord 服务器对象 + channel: 语音频道对象 + user_id: 用户ID + + Raises: + VoicePermissionError: 用户不在频道中时抛出 + """ + member = guild.get_member(user_id) + if not member: + raise VoicePermissionError(f'无法找到用户 {user_id}', ['member_not_found'], user_id, channel.id) + + if not member.voice or member.voice.channel != channel: + raise VoicePermissionError( + f'用户 {member.display_name} 不在语音频道 {channel.name} 中', + ['user_not_in_channel'], + user_id, + channel.id, + ) + + async def _validate_bot_permissions(self, channel: discord.VoiceChannel): + """ + 验证机器人权限 + + @author: @ydzat + + Args: + channel: 语音频道对象 + + Raises: + VoicePermissionError: 权限不足时抛出 + """ + bot_member = channel.guild.me + permissions = channel.permissions_for(bot_member) + + missing_permissions = [] + + if not permissions.connect: + missing_permissions.append('connect') + if not permissions.speak: + missing_permissions.append('speak') + + if missing_permissions: + raise VoicePermissionError( + f'机器人在频道 {channel.name} 中缺少权限: {", ".join(missing_permissions)}', + missing_permissions, + channel_id=channel.id, + ) + + async def cleanup_inactive_connections(self): + """ + 清理无效连接 + + 定期检查并清理已断开或无效的语音连接,释放资源。 + + @author: @ydzat + """ + cleanup_guilds = [] + + for guild_id, conn_info in self.connections.items(): + if not conn_info.voice_client or not conn_info.voice_client.is_connected(): + cleanup_guilds.append(guild_id) + + for guild_id in cleanup_guilds: + await self._disconnect_internal(guild_id) + + if cleanup_guilds: + await self.logger.info(f'清理了 {len(cleanup_guilds)} 个无效的语音连接') + + async def start_monitoring(self): + """ + 开始连接监控 + + @author: @ydzat + """ + if self._cleanup_task is None and self._monitoring_enabled: + self._cleanup_task = asyncio.create_task(self._monitoring_loop()) + + async def stop_monitoring(self): + """ + 停止连接监控 + + @author: @ydzat + """ + self._monitoring_enabled = False + if self._cleanup_task: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + self._cleanup_task = None + + async def _monitoring_loop(self): + """ + 监控循环 + + @author: @ydzat + """ + try: + while self._monitoring_enabled: + await asyncio.sleep(60) # 每分钟检查一次 + await self.cleanup_inactive_connections() + except asyncio.CancelledError: + pass + + async def disconnect_all(self): + """ + 断开所有连接 + + @author: @ydzat + """ + async with self._connection_lock: + guild_ids = list(self.connections.keys()) + for guild_id in guild_ids: + await self._disconnect_internal(guild_id) + + await self.stop_monitoring() + + +class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain, + ) -> typing.Tuple[str, typing.List[discord.File]]: + for ele in message_chain: + if isinstance(ele, platform_message.At): + message_chain.remove(ele) + break + + text_string = '' + files = [] + + for ele in message_chain: + if isinstance(ele, platform_message.Image): + image_bytes = None + filename = f'{uuid.uuid4()}.png' # 默认文件名 + + if ele.base64: + # 处理base64编码的图片 + if ele.base64.startswith('data:'): + # 从data URL中提取文件类型 + data_header = ele.base64.split(',')[0] + if 'jpeg' in data_header or 'jpg' in data_header: + filename = f'{uuid.uuid4()}.jpg' + elif 'gif' in data_header: + filename = f'{uuid.uuid4()}.gif' + elif 'webp' in data_header: + filename = f'{uuid.uuid4()}.webp' + # 去掉data:image/xxx;base64,前缀 + base64_data = ele.base64.split(',')[1] + else: + base64_data = ele.base64 + image_bytes = base64.b64decode(base64_data) + elif ele.url: + # 从URL下载图片 + session = httpclient.get_session() + async with session.get(ele.url) as response: + image_bytes = await response.read() + # 从URL或Content-Type推断文件类型 + content_type = response.headers.get('Content-Type', '') + if 'jpeg' in content_type or 'jpg' in content_type: + filename = f'{uuid.uuid4()}.jpg' + elif 'gif' in content_type: + filename = f'{uuid.uuid4()}.gif' + elif 'webp' in content_type: + filename = f'{uuid.uuid4()}.webp' + elif ele.url.lower().endswith(('.jpg', '.jpeg')): + filename = f'{uuid.uuid4()}.jpg' + elif ele.url.lower().endswith('.gif'): + filename = f'{uuid.uuid4()}.gif' + elif ele.url.lower().endswith('.webp'): + filename = f'{uuid.uuid4()}.webp' + elif ele.path: + # 从文件路径读取图片 + # 确保路径没有空字节 + clean_path = ele.path.replace('\x00', '') + clean_path = os.path.abspath(clean_path) + + if not os.path.exists(clean_path): + continue # 跳过不存在的文件 + + try: + with open(clean_path, 'rb') as f: + image_bytes = f.read() + # 从文件路径获取文件名,保持原始扩展名 + original_filename = os.path.basename(clean_path) + if original_filename and '.' in original_filename: + # 保持原始文件名的扩展名 + ext = original_filename.split('.')[-1].lower() + filename = f'{uuid.uuid4()}.{ext}' + else: + # 如果没有扩展名,尝试从文件内容检测 + if image_bytes.startswith(b'\xff\xd8\xff'): + filename = f'{uuid.uuid4()}.jpg' + elif image_bytes.startswith(b'GIF'): + filename = f'{uuid.uuid4()}.gif' + elif image_bytes.startswith(b'RIFF') and b'WEBP' in image_bytes[:20]: + filename = f'{uuid.uuid4()}.webp' + # 默认保持PNG + except Exception as e: + print(f'Error reading image file {clean_path}: {e}') + continue # 跳过读取失败的文件 + + if image_bytes: + files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename)) + elif isinstance(ele, platform_message.Plain): + text_string += ele.text + elif isinstance(ele, platform_message.Voice): + file_bytes = None + filename = f'{uuid.uuid4()}.mp3' + if ele.base64: + if ele.base64.startswith('data:'): + data_header = ele.base64.split(',')[0] + if 'wav' in data_header: + filename = f'{uuid.uuid4()}.wav' + elif 'mp3' in data_header: + filename = f'{uuid.uuid4()}.mp3' + elif 'ogg' in data_header: + filename = f'{uuid.uuid4()}.ogg' + elif 'm4a' in data_header: + filename = f'{uuid.uuid4()}.m4a' + elif 'aac' in data_header: + filename = f'{uuid.uuid4()}.aac' + elif 'flac' in data_header: + filename = f'{uuid.uuid4()}.flac' + elif 'alac' in data_header: + filename = f'{uuid.uuid4()}.alac' + elif 'opus' in data_header: + filename = f'{uuid.uuid4()}.opus' + elif 'webm' in data_header: + filename = f'{uuid.uuid4()}.webm' + + file_base64 = ele.base64.split(',')[-1] + file_bytes = base64.b64decode(file_base64) + elif ele.url: + session = httpclient.get_session() + async with session.get(ele.url) as response: + file_bytes = await response.read() + if file_bytes: + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + elif isinstance(ele, platform_message.File): + file_bytes = None + filename = f'{uuid.uuid4()}.{ele.name.split(".")[-1]}' + if ele.base64: + if ele.base64.startswith('data:'): + file_base64 = ele.base64.split(',')[1] + file_bytes = base64.b64decode(file_base64) + else: + file_bytes = base64.b64decode(ele.base64) + elif ele.url: + session = httpclient.get_session() + async with session.get(ele.url) as response: + file_bytes = await response.read() + if file_bytes: + files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) + elif isinstance(ele, platform_message.Forward): + for node in ele.node_list: + ( + node_text, + node_files, + ) = await DiscordMessageConverter.yiri2target(node.message_chain) + text_string += node_text + files.extend(node_files) + + return text_string, files + + @staticmethod + async def target2yiri(message: discord.Message) -> platform_message.MessageChain: + lb_msg_list = [] + + msg_create_time = datetime.datetime.fromtimestamp(int(message.created_at.timestamp())) + + lb_msg_list.append(platform_message.Source(id=message.id, time=msg_create_time)) + + element_list = [] + + def text_element_recur( + text_ele: str, + ) -> list[platform_message.MessageComponent]: + if text_ele == '': + return [] + + # <@1234567890> + # @everyone + # @here + at_pattern = re.compile(r'(@everyone|@here|<@[\d]+>)') + at_matches = at_pattern.findall(text_ele) + + if len(at_matches) > 0: + mid_at = at_matches[0] + + text_split = text_ele.split(mid_at) + + mid_at_component = [] + + if mid_at == '@everyone' or mid_at == '@here': + mid_at_component.append(platform_message.AtAll()) + else: + mid_at_component.append(platform_message.At(target=mid_at[2:-1])) + + return text_element_recur(text_split[0]) + mid_at_component + text_element_recur(text_split[1]) + else: + return [platform_message.Plain(text=text_ele)] + + element_list.extend(text_element_recur(message.content)) + + # attachments + for attachment in message.attachments: + session = httpclient.get_session(trust_env=True) + async with session.get(attachment.url) as response: + image_data = await response.read() + image_base64 = base64.b64encode(image_data).decode('utf-8') + image_format = response.headers['Content-Type'] + element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}')) + + return platform_message.MessageChain(element_list) + + +class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.Event) -> discord.Message: + pass + + @staticmethod + async def target2yiri(event: discord.Message) -> platform_events.Event: + message_chain = await DiscordMessageConverter.target2yiri(event) + + if isinstance(event.channel, discord.DMChannel): + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.author.id, + nickname=event.author.name, + remark=event.channel.id, + ), + message_chain=message_chain, + time=event.created_at.timestamp(), + source_platform_object=event, + ) + elif isinstance(event.channel, discord.TextChannel): + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=event.author.id, + member_name=event.author.name, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=event.channel.id, + name=event.channel.name, + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=event.created_at.timestamp(), + source_platform_object=event, + ) + + +class DiscordFormView(discord_ui.View): + """Discord ``ui.View`` that renders one button per Dify form action. + + Each button's click triggers ``adapter._on_form_button_click`` which + acks the interaction, locks the buttons in place, and enqueues a + synthetic ``_dify_form_action`` query so the runner resumes the + workflow. + """ + + # Discord button style mapping for Dify ``button_style`` values. + _STYLE_MAP: typing.ClassVar[dict] = { + 'primary': discord.ButtonStyle.primary, + 'danger': discord.ButtonStyle.danger, + 'warning': discord.ButtonStyle.danger, + 'success': discord.ButtonStyle.success, + 'default': discord.ButtonStyle.secondary, + '': discord.ButtonStyle.secondary, + } + + def __init__( + self, + adapter: 'DiscordAdapter', + session_key: str, + actions: list, + timeout: float = 1800, + ): + super().__init__(timeout=timeout) + self._adapter = adapter + self._session_key = session_key + # Discord caps a view at 25 children (5 rows × 5 buttons). Trim + # silently — most Dify forms have ≤10 actions in practice. + for idx, action in enumerate(actions[:25]): + action_id = str(action.get('id') or '') + label = str(action.get('title') or action_id or f'Option {idx + 1}') + style = self._STYLE_MAP.get( + str(action.get('button_style') or '').lower(), + discord.ButtonStyle.secondary, + ) + # custom_id must be unique within the view and ≤100 chars. + # Encode (session, idx) so we can recover the action even + # if Dify ids contain unsafe characters. + custom_id = f'lb_form:{idx}:{action_id[:80]}'[:100] + button = discord_ui.Button( + label=label[:80], # Discord label limit + style=style, + custom_id=custom_id, + ) + button.callback = self._make_callback(action_id, label) + self.add_item(button) + + def _make_callback(self, action_id: str, action_title: str): + async def _cb(interaction: discord.Interaction): + await self._adapter._on_form_button_click( + interaction=interaction, + session_key=self._session_key, + action_id=action_id, + action_title=action_title, + view=self, + ) + + return _cb + + +class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + bot: discord.Client = pydantic.Field(exclude=True) + + message_converter: DiscordMessageConverter = DiscordMessageConverter() + event_converter: DiscordEventConverter = DiscordEventConverter() + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + + voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None) + + # Injected by botmgr at construction so the form-button callback can + # enqueue a synthetic resume query (`_dify_form_action`) on the pool. + ap: typing.Any = pydantic.Field(exclude=True, default=None) + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): + bot_account_id = config['client_id'] + + listeners = {} + + # 初始化语音连接管理器 + # self.voice_manager: VoiceConnectionManager = None + + adapter_self = self + + class MyClient(discord.Client): + async def on_message(self: discord.Client, message: discord.Message): + if message.author.id == self.user.id or message.author.bot: + return + + lb_event = await adapter_self.event_converter.target2yiri(message) + await adapter_self.listeners[type(lb_event)](lb_event, adapter_self) + + intents = discord.Intents.default() + intents.message_content = True + + args = {} + + # Proxy: config > env var > auto-detect. + # discord.py uses aiohttp which does NOT respect http_proxy env + # vars by default — we must pass proxy= explicitly. + proxy = ( + config.get('proxy') + or os.getenv('http_proxy') + or os.getenv('HTTP_PROXY') + or os.getenv('https_proxy') + or os.getenv('HTTPS_PROXY') + ) + if proxy: + args['proxy'] = proxy + + bot = MyClient(intents=intents, **args) + + super().__init__( + config=config, + logger=logger, + bot_account_id=bot_account_id, + listeners=listeners, + bot=bot, + voice_manager=None, + **kwargs, + ) + + # Per-resp-message-id buffer for the accumulated text yielded by + # the runner. Discord's edit-message ratelimit (5/5s) makes true + # progressive streaming impractical, so we collect chunks and + # render once on is_final. ``_form_data`` on the final chunk + # diverts to the button-view path. + self._stream_buffer: dict[str, str] = {} + # session_key -> {form_data, channel_id, thread_id, sender_id, + # posted_at, view_message_id} + # Populated when we send a form view; consumed when the user + # clicks a button so we know which workflow_run / form_token to + # resume. + self._pending_forms: dict[str, dict] = {} + + # Voice functionality methods + async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient: + """ + 加入语音频道 + + 为指定服务器的语音频道建立连接,支持用户权限验证和连接复用。 + + @author: @ydzat + @version: 1.0 + @since: 2025-07-04 + + Args: + guild_id (int): Discord 服务器ID + channel_id (int): 语音频道ID + user_id (int, optional): 请求用户ID,用于权限验证 + + Returns: + discord.VoiceClient: 语音客户端实例 + + Raises: + VoicePermissionError: 权限不足 + VoiceNetworkError: 网络连接失败 + VoiceConnectionError: 其他连接错误 + """ + if not self.voice_manager: + raise VoiceConnectionError('语音管理器未初始化', 'MANAGER_NOT_READY') + + return await self.voice_manager.join_voice_channel(guild_id, channel_id, user_id) + + async def leave_voice_channel(self, guild_id: int) -> bool: + """ + 离开语音频道 + + 断开指定服务器的语音连接,清理相关资源。 + + @author: @ydzat + @version: 1.0 + @since: 2025-07-04 + + Args: + guild_id (int): Discord 服务器ID + + Returns: + bool: 是否成功断开连接 + """ + if not self.voice_manager: + return False + + return await self.voice_manager.leave_voice_channel(guild_id) + + async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]: + """ + 获取语音客户端 + + 返回指定服务器的语音客户端实例,用于音频播放控制。 + + @author: @ydzat + @version: 1.0 + @since: 2025-07-04 + + Args: + guild_id (int): Discord 服务器ID + + Returns: + Optional[discord.VoiceClient]: 语音客户端实例或 None + """ + if not self.voice_manager: + return None + + return await self.voice_manager.get_voice_client(guild_id) + + async def is_connected_to_voice(self, guild_id: int) -> bool: + """ + 检查语音连接状态 + + @author: @ydzat + @version: 1.0 + @since: 2025-07-04 + + Args: + guild_id (int): Discord 服务器ID + + Returns: + bool: 是否已连接到语音频道 + """ + if not self.voice_manager: + return False + + return await self.voice_manager.is_connected_to_voice(guild_id) + + async def get_voice_connection_status(self, guild_id: int) -> typing.Optional[dict]: + """ + 获取语音连接详细状态 + + 返回包含连接时间、延迟、用户数等详细信息的状态字典。 + + @author: @ydzat + @version: 1.0 + @since: 2025-07-04 + + Args: + guild_id (int): Discord 服务器ID + + Returns: + Optional[dict]: 连接状态信息或 None + """ + if not self.voice_manager: + return None + + return await self.voice_manager.get_connection_status(guild_id) + + async def list_active_voice_connections(self) -> typing.List[dict]: + """ + 列出所有活跃的语音连接 + + @author: @ydzat + @version: 1.0 + @since: 2025-07-04 + + Returns: + List[dict]: 活跃语音连接列表 + """ + if not self.voice_manager: + return [] + + return await self.voice_manager.list_active_connections() + + async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]: + """ + 获取语音频道详细信息 + + 包括频道名称、用户列表、权限信息等。 + + @author: @ydzat + @version: 1.0 + @since: 2025-07-04 + + Args: + guild_id (int): Discord 服务器ID + channel_id (int): 语音频道ID + + Returns: + Optional[dict]: 频道信息字典或 None + """ + if not self.voice_manager: + return None + + return await self.voice_manager.get_voice_channel_info(guild_id, channel_id) + + async def cleanup_voice_connections(self): + """ + 清理无效的语音连接 + + 手动触发语音连接清理,移除已断开或无效的连接。 + + @author: @ydzat + @version: 1.0 + @since: 2025-07-04 + """ + if self.voice_manager: + await self.voice_manager.cleanup_inactive_connections() + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + msg_to_send, files = await self.message_converter.yiri2target(message) + + try: + # 获取频道对象 + channel = self.bot.get_channel(int(target_id)) + if channel is None: + # 如果本地缓存中没有,尝试从API获取 + channel = await self.bot.fetch_channel(int(target_id)) + + args = { + 'content': msg_to_send, + } + + if len(files) > 0: + args['files'] = files + + await channel.send(**args) + + except Exception as e: + await self.logger.error(f'Discord send_message failed: {e}') + raise e + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + msg_to_send, files = await self.message_converter.yiri2target(message) + + # Synthetic events (button-click resume) have no inbound discord + # Message. Route via the channel we cached when the user clicked. + source = message_source.source_platform_object + if not isinstance(source, discord.Message): + await self._reply_synthetic(message_source, msg_to_send, files) + return + + args = { + 'content': msg_to_send, + } + + if len(files) > 0: + args['files'] = files + + if quote_origin: + args['reference'] = source + + has_at = False + + for component in message.root: + if isinstance(component, platform_message.At): + has_at = True + break + + if has_at: + args['mention_author'] = True + + await source.channel.send(**args) + + async def _reply_synthetic( + self, + message_source: platform_events.MessageEvent, + msg_to_send: str, + files: list, + ) -> None: + """Deliver a reply for a button-click-resumed (synthetic) event. + + We don't have an inbound discord.Message to anchor to; instead + look up the channel cached in ``_pending_forms[session_key + + '__last_channel']`` from the most recent button click. + """ + if isinstance(message_source, platform_events.GroupMessage): + # _handle_form_chunk uses channel_id alone as the session + # scope, and launcher_id was set to channel_id when + # synthesizing the event. + session_key = f'c:{message_source.group.id}' + else: + session_key = f'p:{message_source.sender.id}' + + cached = self._pending_forms.get(session_key + '__last_channel') or {} + channel = cached.get('channel') + if channel is None: + if self.ap is not None: + self.ap.logger.warning( + f'Discord: synthetic reply has no cached channel for ' + f'{session_key}; dropping content (len={len(msg_to_send)})' + ) + return + + args: dict[str, typing.Any] = {'content': msg_to_send} + if files: + args['files'] = files + try: + await channel.send(**args) + except Exception: + if self.ap is not None: + self.ap.logger.error(f'Discord: synthetic reply send failed: {traceback.format_exc()}') + + # Discord allows 5 edits per 5 seconds per message. We throttle + # to one edit per 8 runner-chunks (runner already yields every 8 + # text_chunks internally), which stays comfortably within limits. + _STREAM_EDIT_INTERVAL = 8 + + async def is_stream_output_supported(self) -> bool: + return True + + async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool: + """Set up a stream context for progressive editing. + + The first non-empty reply_message_chunk will send the initial + message; subsequent chunks edit it in place. + """ + source = event.source_platform_object + if not isinstance(source, discord.Message): + return False + self._stream_buffer[message_id] = { + 'channel': source.channel, + 'sent_message': None, # discord.Message set on first send + 'last_content': '', + 'chunk_count': 0, + } + return True + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message: typing.Any, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + msg_id = ( + bot_message.get('resp_message_id') + if isinstance(bot_message, dict) + else getattr(bot_message, 'resp_message_id', None) + ) + + text_parts = [m.text for m in message if isinstance(m, platform_message.Plain)] + chunk_text = '\n\n'.join(t for t in text_parts if t) + + form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None + + ctx = self._stream_buffer.get(msg_id) if msg_id else None + + # If the stream ctx was not set up (create_message_card wasn't + # called, e.g. synthetic event), or the final chunk carries a + # form, skip progressive editing entirely. + if ctx is None or form_data: + try: + if form_data and is_final: + await self._handle_form_chunk(message_source, form_data) + elif is_final and chunk_text: + await self.reply_message( + message_source, + platform_message.MessageChain([platform_message.Plain(text=chunk_text)]), + quote_origin, + ) + finally: + self._stream_buffer.pop(msg_id, None) + return + + # Progressive streaming path: send first chunk, edit subsequent. + ctx['chunk_count'] += 1 + + # Runner yields the full accumulated text on each chunk, so we + # always replace (not append). + if chunk_text: + ctx['last_content'] = chunk_text + + sent = ctx['sent_message'] + + if sent is None: + # First non-empty chunk — send the initial message. + if not ctx['last_content']: + return # No content yet, wait for next chunk. + try: + sent = await ctx['channel'].send(ctx['last_content']) + ctx['sent_message'] = sent + except Exception: + if self.ap is not None: + self.ap.logger.error(f'Discord stream send failed: {traceback.format_exc()}') + self._stream_buffer.pop(msg_id, None) + return + + if is_final: + # Final chunk — edit to the full content, then clean up. + if ctx['last_content'] and ctx['last_content'] != sent.content: + try: + await sent.edit(content=ctx['last_content'][:2000]) + except Exception: + pass # Best-effort + self._stream_buffer.pop(msg_id, None) + elif (ctx['chunk_count'] % self._STREAM_EDIT_INTERVAL) == 0: + # Intermediate edit — throttle to avoid rate limits. + if ctx['last_content'] and ctx['last_content'] != sent.content: + try: + await sent.edit(content=ctx['last_content'][:2000]) + except Exception: + pass # Rate-limited or deleted — ignore. + + async def _handle_form_chunk( + self, + message_source: platform_events.MessageEvent, + form_data: dict, + ) -> None: + """Render a Dify form pause as a Discord embed + button View. + + Mirrors the QQ / Telegram / Lark form path: the button's click + callback synthesizes a ``_dify_form_action`` query so the runner's + ``_merge_pending_form_action`` resumes the workflow. + """ + source = message_source.source_platform_object + + actions = form_data.get('actions') or [] + if not actions: + # Nothing clickable — fall back to plain text. + if source is not None: + await self.reply_message( + message_source, + platform_message.MessageChain( + [platform_message.Plain(text=str(form_data.get('node_title') or ''))] + ), + ) + return + + node_title = str(form_data.get('node_title') or 'Confirmation needed') + form_content = str(form_data.get('form_content') or '').strip() + + # Two paths: + # (a) Real message — extract channel from source. + # (b) Synthetic event (button-click resume) — no + # source_platform_object; recover the channel we cached + # when the user clicked. + if isinstance(source, discord.Message): + channel = source.channel + guild_id = str(source.guild.id) if source.guild else '' + sender_id = str(source.author.id) + channel_id = str(source.channel.id) + session_key = f'c:{channel_id}' if guild_id else f'p:{sender_id}' + else: + # Synthetic event — resolve session_key from event shape, + # then look up the cached channel from the click. + if isinstance(message_source, platform_events.GroupMessage): + # launcher_id was set to channel_id when we synthesized. + channel_id = str(message_source.group.id) + session_key = f'c:{channel_id}' + else: + session_key = f'p:{message_source.sender.id}' + channel_id = '' + + cached = self._pending_forms.get(session_key + '__last_channel') + channel = cached.get('channel') if cached else None + guild_id = (cached or {}).get('guild_id', '') + sender_id = str(message_source.sender.id) if message_source.sender else '' + if channel is None: + if self.ap is not None: + self.ap.logger.warning( + f'Discord: synthetic form chunk has no cached channel for ' + f'{session_key}; cannot render form buttons' + ) + return + + body_parts: list[str] = [] + if form_content: + body_parts.append(form_content) + embed_body = '\n\n'.join(body_parts) + # Discord embed.description has a 4096 char limit — defensive trim. + if len(embed_body) > 4000: + embed_body = embed_body[:3990] + '\n\n…(truncated)' + + embed = discord.Embed( + title=node_title[:256], + description=embed_body, + color=discord.Color.blurple(), + ) + + view = DiscordFormView( + adapter=self, + session_key=session_key, + actions=actions, + timeout=1800, # 30 min — matches Dify form_token TTL + ) + + try: + sent_msg = await channel.send(embed=embed, view=view) + except Exception: + if self.ap is not None: + self.ap.logger.error(f'Discord: form view send failed: {traceback.format_exc()}') + return + + self._pending_forms[session_key] = { + 'form_data': form_data, + 'channel_id': channel_id, + 'guild_id': guild_id, + 'sender_id': sender_id, + 'view_message_id': str(sent_msg.id), + 'posted_at': time.time(), + } + + if self.ap is not None: + self.ap.logger.info(f'Discord: form view posted session={session_key} actions={len(actions)}') + + async def _on_form_button_click( + self, + interaction: discord.Interaction, + session_key: str, + action_id: str, + action_title: str, + view: DiscordFormView, + ) -> None: + """Handle a click on a form button — ack, resume the workflow, + and disable the View buttons so the choice is visually locked in.""" + import langbot_plugin.api.entities.builtin.provider.session as provider_session + + # ACK first (3-second deadline before Discord shows "interaction failed"). + try: + await interaction.response.defer() + except discord.HTTPException: + # Already responded somehow — proceed regardless. + pass + + pending = self._pending_forms.get(session_key) + if not pending: + if self.ap is not None: + self.ap.logger.warning( + f'Discord: button click on stale session {session_key}; ignoring (action_id={action_id!r})' + ) + await self._lock_view_message(interaction, view, action_title, stale=True) + return + + form_data: dict = pending.get('form_data') or {} + guild_id = pending.get('guild_id', '') + channel_id = pending.get('channel_id', '') + initiator_id = str(pending.get('sender_id', '') or '') + actor_id = str(interaction.user.id) if interaction.user is not None else initiator_id + if not guild_id and initiator_id and actor_id != initiator_id: + if self.ap is not None: + self.ap.logger.warning( + f'Discord: user {actor_id} cannot act on private form created for {initiator_id}' + ) + await self._lock_view_message(interaction, view, action_title, stale=True) + return + + self._pending_forms.pop(session_key, None) + + # Lock the buttons in place: disable everything, mark chosen one. + await self._lock_view_message(interaction, view, action_title) + + # In group context the launcher remains the channel so Dify resumes + # the original group session. The synthetic sender is still the real + # clicker, preserving actor identity for auditing and routing rules. + if guild_id: + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = channel_id + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = initiator_id or actor_id + + form_action_data = { + 'form_token': form_data.get('form_token', ''), + 'workflow_run_id': form_data.get('workflow_run_id', ''), + 'action_id': action_id, + 'action_title': action_title, + 'node_title': form_data.get('node_title', ''), + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': {}, + } + + message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')]) + + # Synthesize a platform event so the pipeline can run the resume + # query. source_platform_object=None signals "no inbound discord + # message" — reply_message must tolerate this (it falls through + # to channel.send via the cached interaction.channel below). + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=actor_id, + member_name=interaction.user.display_name if interaction.user else '', + permission='MEMBER', + group=platform_entities.Group( + id=launcher_id, + name=channel_id, + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=int(time.time()), + source_platform_object=None, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=actor_id, + nickname=interaction.user.display_name if interaction.user else '', + remark='', + ), + message_chain=message_chain, + time=int(time.time()), + source_platform_object=None, + ) + + if self.ap is None: + if self.logger: + await self.logger.error('Discord: ap not injected; cannot enqueue button-click query') + return + + bot_uuid = '' + pipeline_uuid = form_data.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + # Remember the channel so _reply_synthetic and _handle_form_chunk + # (synthetic-event path) can find a target. guild_id is needed + # to reconstruct the launcher_type on subsequent form pauses. + self._pending_forms[session_key + '__last_channel'] = { + 'channel': interaction.channel, + 'guild_id': guild_id, + 'posted_at': time.time(), + } + + try: + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=actor_id, + message_event=synthetic_event, + message_chain=message_chain, + adapter=self, + pipeline_uuid=pipeline_uuid, + variables={ + '_dify_form_action': form_action_data, + '_routed_by_rule': True, + }, + ) + if self.ap is not None: + self.ap.logger.info( + f'Discord: button-click query enqueued action_id={action_id!r} ' + f'session={session_key} actor_id={actor_id}' + ) + except Exception: + if self.ap is not None: + self.ap.logger.error(f'Discord: enqueue button-click query failed: {traceback.format_exc()}') + + async def _lock_view_message( + self, + interaction: discord.Interaction, + view: DiscordFormView, + chosen_title: str, + stale: bool = False, + ) -> None: + """Disable all buttons on the form view and annotate the chosen + one — mirrors DingTalk/Lark's in-card selection feedback.""" + try: + for child in view.children: + if not isinstance(child, discord_ui.Button): + continue + child.disabled = True + if not stale and child.label == chosen_title: + child.style = discord.ButtonStyle.success + if not (child.label or '').startswith('✓ '): + child.label = f'✓ {child.label}' + view.stop() + if interaction.message is not None: + await interaction.message.edit(view=view) + except Exception: + if self.ap is not None: + self.ap.logger.warning(f'Discord: lock-view-message failed (non-fatal): {traceback.format_exc()}') + + async def is_muted(self, group_id: int) -> bool: + return False + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners.pop(event_type) + + async def run_async(self): + """ + 启动 Discord 适配器 + + 初始化语音管理器并启动 Discord 客户端连接。 + + @author: @ydzat (修改) + """ + async with self.bot: + # 初始化语音管理器 + self.voice_manager = VoiceConnectionManager(self.bot, self.logger) + await self.voice_manager.start_monitoring() + + await self.logger.info('Discord 适配器语音功能已启用') + await self.bot.start(self.config['token'], reconnect=True) + + async def kill(self) -> bool: + """ + 关闭 Discord 适配器 + + 清理语音连接并关闭 Discord 客户端。 + + @author: @ydzat (修改) + """ + if self.voice_manager: + await self.voice_manager.disconnect_all() + + await self.bot.close() + return True diff --git a/src/langbot/pkg/platform/sources/discord.svg b/src/langbot/pkg/platform/sources/discord.svg new file mode 100644 index 0000000..177a059 --- /dev/null +++ b/src/langbot/pkg/platform/sources/discord.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/langbot/pkg/platform/sources/discord.yaml b/src/langbot/pkg/platform/sources/discord.yaml new file mode 100644 index 0000000..28149cb --- /dev/null +++ b/src/langbot/pkg/platform/sources/discord.yaml @@ -0,0 +1,58 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: discord + label: + en_US: Discord + zh_Hans: Discord + zh_Hant: Discord + ja_JP: Discord + th_TH: Discord + vi_VN: Discord + es_ES: Discord + description: + en_US: Discord Adapter + zh_Hans: Discord 适配器,需要可连接 Discord 服务器的网络环境 + zh_Hant: Discord 適配器,需要可連線 Discord 伺服器的網路環境 + ja_JP: Discord アダプター、Discord サーバーに接続可能なネットワーク環境が必要です + th_TH: อะแดปเตอร์ Discord ต้องการสภาพแวดล้อมเครือข่ายที่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ Discord ได้ + vi_VN: Bộ điều hợp Discord, cần môi trường mạng có thể kết nối với máy chủ Discord + es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord + icon: discord.svg +spec: + categories: + - popular + - global + help_links: + zh: https://link.langbot.app/zh/platforms/discord + en: https://link.langbot.app/en/platforms/discord + ja: https://link.langbot.app/ja/platforms/discord + config: + - name: client_id + label: + en_US: Client ID + zh_Hans: 客户端ID + zh_Hant: 用戶端ID + ja_JP: クライアント ID + th_TH: รหัสไคลเอนต์ + vi_VN: ID khách hàng + es_ES: ID de cliente + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_Hans: 令牌 + zh_Hant: 令牌 + ja_JP: トークン + th_TH: โทเค็น + vi_VN: Mã thông báo + es_ES: Token + type: string + required: true + default: "" +execution: + python: + path: ./discord.py + attr: DiscordAdapter diff --git a/src/langbot/pkg/platform/sources/http_bot.py b/src/langbot/pkg/platform/sources/http_bot.py new file mode 100644 index 0000000..16a8919 --- /dev/null +++ b/src/langbot/pkg/platform/sources/http_bot.py @@ -0,0 +1,509 @@ +"""HTTP Bot adapter — standalone server-to-server platform adapter. + +Lets any external backend drive a LangBot pipeline over plain HTTP: + +* **Inbound** — the backend POSTs a signed message to the unified webhook + route ``POST /bots/``; this adapter verifies the signature, builds + a platform event carrying the caller-defined ``session_id`` as the launcher + id, and fires it into the normal pipeline (so message aggregation, N->1, + works for free). +* **Outbound** — every ``reply_message`` / ``reply_message_chunk`` the pipeline + emits is delivered as a signed POST to the configured ``callback_url``. A + single turn may emit many replies (1->M); each is one callback, ordered per + session via a small worker queue. + +Design notes: + +* The callback URL is taken **only** from adapter config (never from the + inbound message) to keep the SSRF surface closed. +* Replies for one ``session_id`` are delivered in ``sequence`` order; the + caller knows a turn is complete when ``is_final: true`` arrives. +* No new HTTP route is registered — the existing unified webhook dispatcher + (``pkg/api/http/controller/groups/webhooks.py``) calls + ``handle_unified_webhook`` on this adapter. + +See docs/platforms/http-bot.md for the full integration guide. +""" + +from __future__ import annotations + +import asyncio +import json +import time +import typing +import uuid +from datetime import datetime + +import aiohttp +import pydantic +import quart + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + +from . import http_bot_signing as signing +from ...utils import httpclient + + +# Error envelope codes (HTTP status -> body code), documented in the design doc. +_ERR = { + 'bad_request': (400, 40001), + 'bad_signature': (401, 40101), + 'duplicate': (409, 40901), + 'too_large': (413, 41301), + 'internal': (500, 50001), +} + +# Max accepted inbound body size (bytes). +_MAX_BODY = 1 * 1024 * 1024 + +# Idempotency dedup window (seconds) and cap. +_IDEMPOTENCY_TTL = 600 +_IDEMPOTENCY_MAX = 4096 + + +class _SessionOutbound: + """Per-session outbound state: ordered delivery queue + sequence counter.""" + + def __init__(self) -> None: + self.queue: asyncio.Queue = asyncio.Queue(maxsize=1000) + self.worker: asyncio.Task | None = None + self.sequence: int = 0 + self.last_was_final: bool = True # so the first reply of a turn starts at seq 1 + + +class _SyncCollector: + """Collects reply parts for a /sync request and resolves when the turn ends.""" + + def __init__(self) -> None: + self.parts: list = [] + self.done: asyncio.Event = asyncio.Event() + + +class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """Standalone HTTP adapter (inbound webhook + outbound callbacks).""" + + bot_uuid: str = pydantic.Field(default='', exclude=True) + + listeners: dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = pydantic.Field(default_factory=dict, exclude=True) + + # session_id -> outbound state + outbound_states: dict[str, _SessionOutbound] = pydantic.Field(default_factory=dict, exclude=True) + # idempotency key -> accepted-at epoch + idempotency_cache: dict[str, float] = pydantic.Field(default_factory=dict, exclude=True) + # session_id -> sync collector (set while a /sync request is awaiting a turn) + sync_waiters: dict[str, '_SyncCollector'] = pydantic.Field(default_factory=dict, exclude=True) + + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): + super().__init__(config=config, logger=logger, **kwargs) + self.bot_account_id = 'http_bot' + self.outbound_states = {} + self.idempotency_cache = {} + self.sync_waiters = {} + + # -- framework hooks ------------------------------------------------------ + + def set_bot_uuid(self, bot_uuid: str) -> None: + """Called by the bot manager so the adapter knows its own bot uuid.""" + object.__setattr__(self, 'bot_uuid', bot_uuid) + + def get_launcher_id(self, event: platform_events.MessageEvent) -> str: + """Map an inbound event to a LangBot launcher id. + + We return the caller-defined ``session_id`` (stashed on the sender / + group id at inbound time) so that each external session maps 1:1 to an + isolated LangBot session. + """ + if isinstance(event, platform_events.GroupMessage): + return str(event.sender.group.id) + return str(event.sender.id) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + func: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None] + ], + ): + self.listeners[event_type] = func + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + func: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None] + ], + ): + self.listeners.pop(event_type, None) + + async def is_muted(self, group_id: int) -> bool: + return False + + async def is_stream_output_supported(self) -> bool: + return True + + async def run_async(self): + # Purely webhook-driven; nothing to poll. Stay alive. + while True: + await asyncio.sleep(3600) + + async def kill(self): + # Cancel any outbound workers. + for state in self.outbound_states.values(): + if state.worker and not state.worker.done(): + state.worker.cancel() + return True + + # -- inbound -------------------------------------------------------------- + + def _err(self, kind: str, detail: str = ''): + status, code = _ERR[kind] + return quart.jsonify({'code': code, 'msg': detail or kind, 'data': None}), status + + def _prune_idempotency(self) -> None: + now = time.time() + if len(self.idempotency_cache) > _IDEMPOTENCY_MAX: + self.idempotency_cache.clear() + return + expired = [k for k, ts in self.idempotency_cache.items() if now - ts > _IDEMPOTENCY_TTL] + for k in expired: + self.idempotency_cache.pop(k, None) + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + """Handle an inbound POST from the unified webhook dispatcher. + + Sub-path routing: + (no path) -> push a message + "reset" -> reset a session's conversation (body: {session_id, session_type?}) + "sync" -> push a message and wait for the final reply (collapses 1->M) + """ + object.__setattr__(self, 'bot_uuid', bot_uuid) + + if path == 'reset': + return await self._handle_reset(request) + if path == 'sync': + return await self._handle_inbound(request, sync=True) + if path in ('', None): + return await self._handle_inbound(request, sync=False) + return self._err('bad_request', f'unknown sub-path: {path}') + + async def _read_and_verify(self, request) -> tuple[dict | None, typing.Any]: + """Read body, enforce size + signature. Returns (data, error_response).""" + body = await request.get_data() + if body and len(body) > _MAX_BODY: + return None, self._err('too_large', 'message too large') + + if self.config.get('signature_required', True): + ok, reason = signing.verify( + secret=self.config.get('inbound_secret', ''), + body=body, + timestamp=request.headers.get(signing.HEADER_TIMESTAMP), + signature=request.headers.get(signing.HEADER_SIGNATURE), + ) + if not ok: + await self.logger.warning(f'http_bot inbound signature rejected: {reason}') + return None, self._err('bad_signature', f'invalid signature: {reason}') + + try: + data = json.loads(body) + except (json.JSONDecodeError, ValueError): + return None, self._err('bad_request', 'body is not valid JSON') + if not isinstance(data, dict): + return None, self._err('bad_request', 'body must be a JSON object') + return data, None + + def _build_event(self, data: dict) -> tuple[platform_events.MessageEvent, str, str, str]: + """Build a platform event from inbound data. + + Returns (event, session_id, session_type, message_id). + """ + session_id = str(data['session_id']) + session_type = data.get('session_type') or self.config.get('default_session_type', 'person') + sender_meta = data.get('sender') or {} + sender_name = str(sender_meta.get('name', 'User')) + + message_id = 'in_' + uuid.uuid4().hex + chain = platform_message.MessageChain.model_validate(data['message']) + # Carry the inbound message id + timestamp as the Source component. + chain.insert(0, platform_message.Source(id=message_id, time=datetime.now())) + + if session_type == 'group': + group = platform_entities.Group( + id=session_id, + name=str(sender_meta.get('group_name', session_id)), + permission=platform_entities.Permission.Member, + ) + sender = platform_entities.GroupMember( + id=str(sender_meta.get('id', session_id)), + member_name=sender_name, + group=group, + permission=platform_entities.Permission.Member, + ) + event = platform_events.GroupMessage(sender=sender, message_chain=chain, time=datetime.now().timestamp()) + else: + sender = platform_entities.Friend(id=session_id, nickname=sender_name, remark=sender_name) + event = platform_events.FriendMessage(sender=sender, message_chain=chain, time=datetime.now().timestamp()) + return event, session_id, session_type, message_id + + async def _handle_inbound(self, request, sync: bool): + data, err = await self._read_and_verify(request) + if err is not None: + return err + + if 'session_id' not in data or 'message' not in data: + return self._err('bad_request', 'session_id and message are required') + + # Idempotency. + idem = request.headers.get(signing.HEADER_IDEMPOTENCY) + if idem: + self._prune_idempotency() + if idem in self.idempotency_cache: + return self._err('duplicate', 'idempotency key already accepted') + self.idempotency_cache[idem] = time.time() + + try: + event, session_id, session_type, message_id = self._build_event(data) + except Exception as e: # noqa: BLE001 + return self._err('bad_request', f'failed to parse message: {e}') + + listener = self.listeners.get(type(event)) + if listener is None: + return self._err('internal', 'no listener registered for event type') + + if sync: + return await self._run_sync(event, listener, session_id, message_id) + + # Fire-and-collect: kick the pipeline, return 202 immediately. + asyncio.create_task(listener(event, self)) + return quart.jsonify( + { + 'code': 0, + 'msg': 'accepted', + 'data': { + 'session_id': session_id, + 'accepted_message_id': message_id, + 'aggregating': True, + }, + } + ), 202 + + async def _handle_reset(self, request): + data, err = await self._read_and_verify(request) + if err is not None: + return err + if 'session_id' not in data: + return self._err('bad_request', 'session_id is required') + + session_id = str(data['session_id']) + session_type = data.get('session_type') or self.config.get('default_session_type', 'person') + launcher_type = 'group' if session_type == 'group' else 'person' + + removed = await self._reset_session(launcher_type, session_id) + return quart.jsonify({'code': 0, 'msg': 'reset', 'data': {'session_id': session_id, 'removed': removed}}), 200 + + async def _reset_session(self, launcher_type: str, launcher_id: str) -> bool: + """Drop the matching session so the next message starts a fresh conversation.""" + sess_mgr = self.ap.sess_mgr + before = len(sess_mgr.session_list) + sess_mgr.session_list = [ + s + for s in sess_mgr.session_list + if not ( + str(s.launcher_type.value if hasattr(s.launcher_type, 'value') else s.launcher_type) == launcher_type + and str(s.launcher_id) == launcher_id + ) + ] + return len(sess_mgr.session_list) < before + + # -- outbound ------------------------------------------------------------- + + @staticmethod + def _extract_session_id(message_source: platform_events.MessageEvent) -> str: + if isinstance(message_source, platform_events.GroupMessage): + return str(message_source.sender.group.id) + return str(message_source.sender.id) + + @staticmethod + def _extract_reply_to(message_source: platform_events.MessageEvent) -> str: + for comp in message_source.message_chain: + if isinstance(comp, platform_message.Source): + return str(comp.id) + return '' + + def _next_sequence(self, session_id: str, is_final: bool) -> int: + state = self.outbound_states.setdefault(session_id, _SessionOutbound()) + if state.last_was_final: + state.sequence = 1 + else: + state.sequence += 1 + state.last_was_final = is_final + return state.sequence + + async def _enqueue_callback(self, session_id: str, payload: dict) -> None: + state = self.outbound_states.setdefault(session_id, _SessionOutbound()) + if state.worker is None or state.worker.done(): + state.worker = asyncio.create_task(self._outbound_worker(session_id, state)) + try: + state.queue.put_nowait(payload) + except asyncio.QueueFull: + # Drop oldest to bound memory, then enqueue (best-effort, at-least-once). + try: + state.queue.get_nowait() + except asyncio.QueueEmpty: + pass + await self.logger.warning(f'http_bot outbound queue full for session {session_id}; dropped oldest') + state.queue.put_nowait(payload) + + async def _outbound_worker(self, session_id: str, state: _SessionOutbound) -> None: + while True: + payload = await state.queue.get() + try: + await self._deliver_callback(payload) + except Exception as e: # noqa: BLE001 + await self.logger.error(f'http_bot callback delivery failed for {session_id}: {e}') + finally: + state.queue.task_done() + + async def _deliver_callback(self, payload: dict) -> None: + callback_url = self.config.get('callback_url', '') + if not callback_url: + await self.logger.warning('http_bot has no callback_url configured; dropping reply') + return + + body = json.dumps(payload, ensure_ascii=False).encode() + secret = self.config.get('outbound_secret') or self.config.get('inbound_secret', '') + ts, sig = signing.sign(secret, body) + headers = { + 'Content-Type': 'application/json', + signing.HEADER_TIMESTAMP: ts, + signing.HEADER_SIGNATURE: sig, + } + timeout = aiohttp.ClientTimeout(total=int(self.config.get('callback_timeout', 15))) + max_retries = int(self.config.get('callback_max_retries', 3)) + + session = httpclient.get_session() + attempt = 0 + while True: + attempt += 1 + try: + async with session.post(callback_url, data=body, headers=headers, timeout=timeout) as resp: + if resp.status < 400: + return + if resp.status < 500 or attempt > max_retries: + await self.logger.warning(f'http_bot callback {callback_url} -> {resp.status}, giving up') + return + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + if attempt > max_retries: + await self.logger.warning(f'http_bot callback {callback_url} failed after {attempt} tries: {e}') + return + await asyncio.sleep(min(2 ** (attempt - 1), 30)) + + async def _emit_reply( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + is_final: bool, + stream: bool, + ) -> dict: + session_id = self._extract_session_id(message_source) + reply_to = self._extract_reply_to(message_source) + sequence = self._next_sequence(session_id, is_final) + parts = [c.model_dump() if hasattr(c, 'model_dump') else c.__dict__ for c in message] + payload = { + 'session_id': session_id, + 'reply_to': reply_to, + 'sequence': sequence, + 'is_final': is_final, + 'stream': stream, + 'message': parts, + 'timestamp': datetime.now().isoformat(), + } + + # If a /sync request is awaiting this session, collect instead of POSTing. + collector = self.sync_waiters.get(session_id) + if collector is not None: + collector.parts.extend(parts) + if is_final: + collector.done.set() + return payload + + await self._enqueue_callback(session_id, payload) + return payload + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain) -> dict: + """Proactively push a message to a session (target_id == session_id).""" + sequence = self._next_sequence(str(target_id), is_final=True) + payload = { + 'session_id': str(target_id), + 'reply_to': '', + 'sequence': sequence, + 'is_final': True, + 'stream': False, + 'message': [c.model_dump() if hasattr(c, 'model_dump') else c.__dict__ for c in message], + 'timestamp': datetime.now().isoformat(), + } + await self._enqueue_callback(str(target_id), payload) + return payload + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> dict: + return await self._emit_reply(message_source, message, is_final=True, stream=False) + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ) -> dict: + message_is_final = is_final and getattr(bot_message, 'tool_calls', None) is None + return await self._emit_reply(message_source, message, is_final=message_is_final, stream=True) + + # -- sync convenience mode ------------------------------------------------ + + async def _run_sync(self, event, listener, session_id: str, message_id: str): + """Push a message and wait for the final reply, collapsing 1->M parts. + + Lossy by design (drops streaming/ordering nuance); documented as such. + Concurrency-safe: routing is via the per-session ``_sync_waiters`` + registry that ``_emit_reply`` consults, not by patching methods. + """ + if session_id in self.sync_waiters: + return self._err('duplicate', 'a sync request is already in flight for this session') + + collector = _SyncCollector() + self.sync_waiters[session_id] = collector + try: + asyncio.create_task(listener(event, self)) + timeout = int(self.config.get('callback_timeout', 15)) * 4 + try: + await asyncio.wait_for(collector.done.wait(), timeout=timeout) + except asyncio.TimeoutError: + await self.logger.warning(f'http_bot sync wait timed out for session {session_id}') + finally: + self.sync_waiters.pop(session_id, None) + + return quart.jsonify( + { + 'code': 0, + 'msg': 'ok', + 'data': { + 'session_id': session_id, + 'reply_to': message_id, + 'message': collector.parts, + }, + } + ), 200 diff --git a/src/langbot/pkg/platform/sources/http_bot.svg b/src/langbot/pkg/platform/sources/http_bot.svg new file mode 100644 index 0000000..e092e75 --- /dev/null +++ b/src/langbot/pkg/platform/sources/http_bot.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/langbot/pkg/platform/sources/http_bot.yaml b/src/langbot/pkg/platform/sources/http_bot.yaml new file mode 100644 index 0000000..56ef57f --- /dev/null +++ b/src/langbot/pkg/platform/sources/http_bot.yaml @@ -0,0 +1,153 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: http_bot + label: + en_US: HTTP Bot + zh_Hans: HTTP 通用接入 + zh_Hant: HTTP 通用接入 + ja_JP: HTTP ボット + description: + en_US: Integrate any backend over plain HTTP. Push messages in via a signed webhook, receive replies on a callback URL. Server-to-server, no long-lived connection. Preserves message aggregation (N->1) and multi-part replies (1->M). + zh_Hans: 通过 HTTP 接入任意后端系统。以签名 Webhook 推入消息,在回调地址接收回复。面向服务间集成,无需长连接。完整保留消息聚合(多条合一)与多段回复(一条问、多条回)能力。 + zh_Hant: 透過 HTTP 接入任意後端系統。以簽名 Webhook 推入訊息,在回調地址接收回覆。面向服務間整合,無需長連線。完整保留訊息聚合(多條合一)與多段回覆(一條問、多條回)能力。 + ja_JP: 任意のバックエンドを HTTP で接続。署名付き Webhook でメッセージを送信し、コールバック URL で返信を受信します。サーバー間連携、長時間接続不要。メッセージ集約(N→1)とマルチパート返信(1→M)に対応。 + icon: http_bot.svg +spec: + categories: + - popular + - global + help_links: + zh: https://docs.langbot.app/zh/platforms/http-bot + en: https://docs.langbot.app/en/platforms/http-bot + ja: https://docs.langbot.app/ja/platforms/http-bot + config: + - name: webhook_url + label: + en_US: Inbound Webhook URL + zh_Hans: 入站 Webhook 地址 + zh_Hant: 入站 Webhook 地址 + ja_JP: 受信 Webhook URL + description: + en_US: Copy this URL. Your backend POSTs messages here (signed with the inbound secret). + zh_Hans: 复制此地址。你的后端将消息以签名方式 POST 到这里。 + zh_Hant: 複製此地址。你的後端將訊息以簽名方式 POST 到這裡。 + ja_JP: この URL をコピーしてください。バックエンドは署名付きでここにメッセージを POST します。 + type: webhook-url + required: false + default: "" + - name: inbound_secret + label: + en_US: Inbound Signing Secret + zh_Hans: 入站签名密钥 + zh_Hant: 入站簽名密鑰 + ja_JP: 受信署名シークレット + description: + en_US: HMAC-SHA256 secret your backend uses to sign inbound requests. LangBot verifies every inbound POST with it. + zh_Hans: 你的后端用于对入站请求做 HMAC-SHA256 签名的密钥;LangBot 据此校验每个入站 POST。 + zh_Hant: 你的後端用於對入站請求做 HMAC-SHA256 簽名的密鑰;LangBot 據此校驗每個入站 POST。 + ja_JP: バックエンドが受信リクエストの署名に使う HMAC-SHA256 シークレット。LangBot は受信 POST ごとに検証します。 + type: string + required: true + default: "" + - name: callback_url + label: + en_US: Outbound Callback URL + zh_Hans: 出站回调地址 + zh_Hant: 出站回調地址 + ja_JP: 送信コールバック URL + description: + en_US: Where LangBot POSTs replies. One turn may trigger multiple callbacks (1->M). For security the callback URL is taken ONLY from this config and cannot be overridden per-message. + zh_Hans: LangBot 将回复 POST 到此地址。一轮对话可能触发多次回调(一问多答)。出于安全考虑,回调地址只取自此配置,不允许逐条消息覆盖。 + zh_Hant: LangBot 將回覆 POST 到此地址。一輪對話可能觸發多次回調(一問多答)。出於安全考慮,回調地址只取自此配置,不允許逐條訊息覆蓋。 + ja_JP: LangBot が返信を POST する先。1 ターンで複数回のコールバック(1→M)が発生し得ます。セキュリティ上、コールバック URL はこの設定からのみ取得し、メッセージ単位で上書きできません。 + type: string + required: true + default: "" + - name: outbound_secret + label: + en_US: Outbound Signing Secret + zh_Hans: 出站签名密钥 + zh_Hant: 出站簽名密鑰 + ja_JP: 送信署名シークレット + description: + en_US: HMAC-SHA256 secret LangBot uses to sign outbound callbacks so your receiver can verify them. Falls back to the inbound secret when empty. + zh_Hans: LangBot 用于对出站回调签名的密钥,供你的接收端校验。留空时回退使用入站密钥。 + zh_Hant: LangBot 用於對出站回調簽名的密鑰,供你的接收端校驗。留空時回退使用入站密鑰。 + ja_JP: LangBot が送信コールバックの署名に使う HMAC-SHA256 シークレット。受信側で検証できます。空の場合は受信シークレットを使用します。 + type: string + required: false + default: "" + - name: default_session_type + label: + en_US: Default Session Type + zh_Hans: 默认会话类型 + zh_Hant: 預設會話類型 + ja_JP: デフォルトセッションタイプ + description: + en_US: Session type used when an inbound message omits session_type. + zh_Hans: 入站消息未携带 session_type 时使用的会话类型。 + zh_Hant: 入站訊息未攜帶 session_type 時使用的會話類型。 + ja_JP: 受信メッセージに session_type がない場合に使用するセッションタイプ。 + type: select + options: + - name: person + label: + en_US: Person (1-on-1) + zh_Hans: 个人(一对一) + zh_Hant: 個人(一對一) + ja_JP: 個人(1 対 1) + - name: group + label: + en_US: Group + zh_Hans: 群组 + zh_Hant: 群組 + ja_JP: グループ + required: false + default: person + - name: signature_required + label: + en_US: Require Inbound Signature + zh_Hans: 强制入站签名校验 + zh_Hant: 強制入站簽名校驗 + ja_JP: 受信署名を必須にする + description: + en_US: When enabled (recommended), every inbound POST must carry a valid signature. Disable ONLY for local development behind a trusted network. + zh_Hans: 开启(推荐)后,每个入站 POST 都必须带有效签名。仅在受信任内网的本地开发时关闭。 + zh_Hant: 開啟(推薦)後,每個入站 POST 都必須帶有效簽名。僅在受信任內網的本地開發時關閉。 + ja_JP: 有効(推奨)にすると、すべての受信 POST に有効な署名が必要です。信頼できるネットワーク内のローカル開発時のみ無効化してください。 + type: boolean + required: false + default: true + - name: callback_timeout + label: + en_US: Callback Timeout (seconds) + zh_Hans: 回调超时(秒) + zh_Hant: 回調逾時(秒) + ja_JP: コールバックタイムアウト(秒) + description: + en_US: Per-callback HTTP timeout. + zh_Hans: 单次回调的 HTTP 超时时间。 + zh_Hant: 單次回調的 HTTP 逾時時間。 + ja_JP: コールバックごとの HTTP タイムアウト。 + type: integer + required: false + default: 15 + - name: callback_max_retries + label: + en_US: Callback Max Retries + zh_Hans: 回调最大重试次数 + zh_Hant: 回調最大重試次數 + ja_JP: コールバック最大リトライ回数 + description: + en_US: Retries on timeout or 5xx, with exponential backoff. + zh_Hans: 超时或 5xx 时按指数退避重试的次数。 + zh_Hant: 逾時或 5xx 時按指數退避重試的次數。 + ja_JP: タイムアウトまたは 5xx 時に指数バックオフでリトライする回数。 + type: integer + required: false + default: 3 +execution: + python: + path: ./http_bot.py + attr: HttpBotAdapter diff --git a/src/langbot/pkg/platform/sources/http_bot_signing.py b/src/langbot/pkg/platform/sources/http_bot_signing.py new file mode 100644 index 0000000..0578721 --- /dev/null +++ b/src/langbot/pkg/platform/sources/http_bot_signing.py @@ -0,0 +1,95 @@ +"""HMAC signing utilities for the HTTP Bot adapter. + +A dependency-free, symmetric HMAC-SHA256 scheme used in *both* directions: + + signing_string = "{timestamp}." + raw_body_bytes + signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string)) + +Inbound requests are signed by the caller and verified here; outbound +callbacks are signed here and verified by the caller. The scheme is trivial to +reproduce in any language (see docs/platforms/http-bot.md for JS/curl). +""" + +from __future__ import annotations + +import hashlib +import hmac +import time + +# Header names (kept here so adapter + clients agree on a single source). +HEADER_TIMESTAMP = 'X-LB-Timestamp' +HEADER_SIGNATURE = 'X-LB-Signature' +HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key' + +# Maximum allowed clock skew between signer and verifier (seconds). +DEFAULT_REPLAY_WINDOW = 300 + + +def compute_signature(secret: str, body: bytes, timestamp: str | int) -> str: + """Compute the ``sha256=`` signature for *body* at *timestamp*. + + Args: + secret: Shared HMAC secret. + body: Raw request body bytes (exactly as sent on the wire). + timestamp: Unix timestamp (seconds) as str or int. + + Returns: + The signature string, e.g. ``sha256=ab12...``. + """ + signing_string = f'{timestamp}.'.encode() + body + digest = hmac.new(secret.encode(), signing_string, hashlib.sha256).hexdigest() + return f'sha256={digest}' + + +def sign(secret: str, body: bytes, timestamp: int | None = None) -> tuple[str, str]: + """Produce ``(timestamp, signature)`` for an outbound request. + + Args: + secret: Shared HMAC secret. + body: Raw request body bytes. + timestamp: Optional fixed timestamp; defaults to ``int(time.time())``. + + Returns: + ``(timestamp_str, signature_str)``. + """ + ts = str(timestamp if timestamp is not None else int(time.time())) + return ts, compute_signature(secret, body, ts) + + +def verify( + secret: str, + body: bytes, + timestamp: str | None, + signature: str | None, + replay_window: int = DEFAULT_REPLAY_WINDOW, +) -> tuple[bool, str]: + """Verify an inbound signature. + + Args: + secret: Shared HMAC secret. + body: Raw request body bytes. + timestamp: Value of the timestamp header. + signature: Value of the signature header. + replay_window: Max allowed skew in seconds. + + Returns: + ``(ok, reason)``. ``reason`` is empty when ``ok`` is True, otherwise a + short machine-friendly cause (``missing_headers`` / ``bad_timestamp`` / + ``expired`` / ``signature_mismatch``). + """ + if not timestamp or not signature: + return False, 'missing_headers' + + try: + ts_int = int(float(timestamp)) + except (ValueError, TypeError): + return False, 'bad_timestamp' + + if abs(int(time.time()) - ts_int) > replay_window: + return False, 'expired' + + expected = compute_signature(secret, body, timestamp) + if not hmac.compare_digest(expected, signature): + return False, 'signature_mismatch' + + return True, '' diff --git a/src/langbot/pkg/platform/sources/kook.png b/src/langbot/pkg/platform/sources/kook.png new file mode 100644 index 0000000..ba6ea15 Binary files /dev/null and b/src/langbot/pkg/platform/sources/kook.png differ diff --git a/src/langbot/pkg/platform/sources/kook.py b/src/langbot/pkg/platform/sources/kook.py new file mode 100644 index 0000000..5a6bade --- /dev/null +++ b/src/langbot/pkg/platform/sources/kook.py @@ -0,0 +1,682 @@ +from __future__ import annotations + +import typing +import asyncio +import json +import base64 +import zlib +import traceback +import time + +import aiohttp + +from langbot.pkg.utils import httpclient +import websockets +import pydantic + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + + +class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + """Convert between LangBot MessageChain and KOOK message format""" + + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]: + """ + Convert LangBot MessageChain to KOOK message format + + Returns: + tuple: (content, message_type) + - content: message content string + - message_type: 1=text, 2=image, 4=file, 9=KMarkdown + """ + content_parts = [] + message_type = 1 # Default to text + + for component in message_chain: + if isinstance(component, platform_message.Plain): + content_parts.append(component.text) + elif isinstance(component, platform_message.At): + # KOOK mention format: (met)user_id(met) + if component.target: + content_parts.append(f'(met){component.target}(met)') + elif isinstance(component, platform_message.AtAll): + # KOOK @all format: (met)all(met) + content_parts.append('(met)all(met)') + elif isinstance(component, platform_message.Image): + # For images, we need to upload first via KOOK's asset API + # For now, we'll send the image URL if available + if component.url: + content_parts.append(component.url) + message_type = 2 # Image message type + elif isinstance(component, platform_message.Forward): + # Handle forward messages by concatenating content + for node in component.node_list: + forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain) + content_parts.append(forward_content) + # Ignore Source and other components + + content = ''.join(content_parts) + return content, message_type + + @staticmethod + async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain: + """ + Convert KOOK message format to LangBot MessageChain + + Args: + kook_message: KOOK message event data dict + bot_account_id: Bot's account ID for handling role mentions + """ + components = [] + + msg_type = kook_message.get('type', 1) + content = kook_message.get('content', '') + extra = kook_message.get('extra', {}) + + # Handle mentions + mentions = extra.get('mention', []) + mention_all = extra.get('mention_all', False) + mention_roles = extra.get('mention_roles', []) + + if mention_all: + components.append(platform_message.AtAll()) + + for mention_id in mentions: + components.append(platform_message.At(target=str(mention_id))) + + # Handle role mentions (when bot is mentioned via role) + # In KOOK, when a role that the bot has is mentioned, we receive it as a role mention + # We need to convert this to an At with the bot's account ID for the pipeline to recognize it + if mention_roles and bot_account_id: + # Add an At component with the bot's account ID when any role is mentioned + # This is because KOOK bots are often assigned roles and @role mentions should trigger responses + components.append(platform_message.At(target=bot_account_id)) + + # Strip mention patterns from content + # Remove user mention patterns: (met)USER_ID(met) + for mention_id in mentions: + content = content.replace(f'(met){mention_id}(met)', '') + + # Remove @all pattern + if mention_all: + content = content.replace('(met)all(met)', '') + + # Remove role mention patterns: (rol)ROLE_ID(rol) + for role_id in mention_roles: + content = content.replace(f'(rol){role_id}(rol)', '') + + # Clean up extra whitespace + content = content.strip() + + # Handle different message types + if msg_type == 1: # Text message + if content: + components.append(platform_message.Plain(text=content)) + elif msg_type == 2: # Image message + # Image content is typically a URL + if content: + # Download image and convert to base64 + try: + session = httpclient.get_session() + async with session.get(content) as response: + if response.status == 200: + image_bytes = await response.read() + image_base64 = base64.b64encode(image_bytes).decode('utf-8') + # Detect image format + content_type = response.headers.get('Content-Type', 'image/png') + components.append( + platform_message.Image(base64=f'data:{content_type};base64,{image_base64}') + ) + except Exception: + # If download fails, just add as plain text + components.append(platform_message.Plain(text=f'[Image: {content}]')) + elif msg_type == 4: # File message + # For file messages, content is typically the file URL + attachments = extra.get('attachments', {}) + file_name = attachments.get('name', 'file') + components.append(platform_message.File(url=content, name=file_name)) + elif msg_type == 8: # Audio message + # For audio messages, content is typically the audio URL + attachments = extra.get('attachments', {}) + components.append(platform_message.Voice(url=content)) + elif msg_type == 9: # KMarkdown message + # Note: content is already stripped of mention patterns above + if content: + components.append(platform_message.Plain(text=content)) + elif msg_type == 10: # Card message + # Card messages are complex, for now just indicate it's a card + components.append(platform_message.Plain(text='[Card Message]')) + else: + # Other message types, just use content as plain text + if content: + components.append(platform_message.Plain(text=content)) + + return platform_message.MessageChain(components) + + +class KookEventConverter(abstract_platform_adapter.AbstractEventConverter): + """Convert between LangBot events and KOOK events""" + + @staticmethod + async def yiri2target(event: platform_events.MessageEvent): + """Convert LangBot event to KOOK event (not implemented)""" + pass + + @staticmethod + async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageEvent: + """ + Convert KOOK event to LangBot MessageEvent + + Args: + kook_event: KOOK event data dict containing channel_type, type, etc. + bot_account_id: Bot's account ID for handling role mentions + + Returns: + FriendMessage or GroupMessage depending on channel_type + """ + channel_type = kook_event.get('channel_type') + author_id = kook_event.get('author_id') + target_id = kook_event.get('target_id') + msg_timestamp = kook_event.get('msg_timestamp', int(time.time() * 1000)) + extra = kook_event.get('extra', {}) + + # Convert message to MessageChain + message_chain = await KookMessageConverter.target2yiri(kook_event, bot_account_id) + + # Convert timestamp from milliseconds to seconds + event_time = msg_timestamp / 1000.0 + + if channel_type == 'PERSON': + # Direct/Private message + author = extra.get('author', {}) + author_name = author.get('nickname', author.get('username', str(author_id))) + + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=str(author_id), + nickname=author_name, + remark=str(author_id), + ), + message_chain=message_chain, + time=event_time, + source_platform_object=kook_event, + ) + elif channel_type == 'GROUP': + # Guild/Server channel message + author = extra.get('author', {}) + author_name = author.get('nickname', author.get('username', str(author_id))) + + # guild_id = extra.get('guild_id', '') + channel_name = extra.get('channel_name', str(target_id)) + + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=str(author_id), + member_name=author_name, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=str(target_id), # Channel ID + name=channel_name, + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=event_time, + source_platform_object=kook_event, + ) + else: + # Fallback to FriendMessage for unknown channel types + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=str(author_id), + nickname=str(author_id), + remark=str(author_id), + ), + message_chain=message_chain, + time=event_time, + source_platform_object=kook_event, + ) + + +class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """KOOK platform adapter for LangBot""" + + config: dict + message_converter: KookMessageConverter = KookMessageConverter() + event_converter: KookEventConverter = KookEventConverter() + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + + # WebSocket connection + ws: typing.Optional[websockets.WebSocketClientProtocol] = pydantic.Field(exclude=True, default=None) + ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None) + heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None) + running: bool = pydantic.Field(exclude=True, default=False) + + # Connection state + session_id: str = pydantic.Field(exclude=True, default='') + current_sn: int = pydantic.Field(exclude=True, default=0) + gateway_url: str = pydantic.Field(exclude=True, default='') + + # HTTP session + http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None) + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): + # Debug: Track init + with open('/tmp/kook_adapter_init.txt', 'w') as f: + f.write(f'KOOK adapter __init__ called at {time.time()}\n') + + # Validate required config + if 'token' not in config: + raise Exception('KOOK adapter requires "token" in config') + + super().__init__( + config=config, + logger=logger, + bot_account_id='', # Will be set after connection + listeners={}, + **kwargs, + ) + + async def _get_gateway_url(self) -> str: + """Get WebSocket gateway URL from KOOK API""" + base_url = 'https://www.kookapp.cn/api/v3/gateway/index' + + # Always use compression for better performance + params = {'compress': 1} + + headers = { + 'Authorization': f'Bot {self.config["token"]}', + } + + session = httpclient.get_session() + async with session.get(base_url, params=params, headers=headers) as response: + if response.status == 200: + data = await response.json() + if data.get('code') == 0: + gateway_url = data['data']['url'] + return gateway_url + else: + raise Exception(f'Failed to get gateway URL: {data.get("message")}') + else: + raise Exception(f'Failed to get gateway URL: HTTP {response.status}') + + async def _get_bot_user_info(self) -> dict: + """Get bot's own user information from KOOK API""" + base_url = 'https://www.kookapp.cn/api/v3/user/me' + + headers = { + 'Authorization': f'Bot {self.config["token"]}', + } + + session = httpclient.get_session() + async with session.get(base_url, headers=headers) as response: + if response.status == 200: + data = await response.json() + if data.get('code') == 0: + user_info = data['data'] + return user_info + else: + raise Exception(f'Failed to get bot user info: {data.get("message")}') + else: + raise Exception(f'Failed to get bot user info: HTTP {response.status}') + + async def _handle_hello(self, data: dict): + """Handle HELLO signal (signal 1)""" + session_id = data.get('session_id', '') + self.session_id = session_id + await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {session_id}') + + async def _handle_event(self, data: dict, sn: int): + """Handle EVENT signal (signal 0)""" + self.current_sn = max(self.current_sn, sn) + + # Check if this is a message event + event_type = data.get('type') + channel_type = data.get('channel_type') + author_id = data.get('author_id') + + # Ignore messages from bot itself to prevent infinite loops + if self.bot_account_id and str(author_id) == self.bot_account_id: + return + + # Only process text messages (type 1, 2, 4, 8, 9, 10) in GROUP or PERSON channels + if event_type in [1, 2, 4, 8, 9, 10] and channel_type in ['GROUP', 'PERSON']: + try: + # Convert to LangBot event + lb_event = await self.event_converter.target2yiri(data, self.bot_account_id) + + # Call registered listener + event_class = type(lb_event) + if event_class in self.listeners: + await self.listeners[event_class](lb_event, self) + except Exception as e: + await self.logger.error(f'Error handling KOOK event: {e}\n{traceback.format_exc()}') + + async def _handle_pong(self, data: dict): + """Handle PONG signal (signal 3)""" + # PONG received, connection is healthy + pass + + async def _heartbeat_loop(self): + """Send PING every 30 seconds""" + try: + while self.running and self.ws: + await asyncio.sleep(30) + + if self.ws: + try: + ping_msg = { + 's': 2, # PING signal + 'sn': self.current_sn, + } + await self.ws.send(json.dumps(ping_msg)) + except Exception: + # Connection closed or send failed, exit loop + break + except asyncio.CancelledError: + pass + except Exception as e: + await self.logger.error(f'Heartbeat error: {e}') + + async def _websocket_loop(self): + """Main WebSocket event loop""" + retry_count = 0 + max_retries = 3 + + while self.running and retry_count < max_retries: + try: + # Get gateway URL if not already retrieved + if not self.gateway_url: + self.gateway_url = await self._get_gateway_url() + + # Connect to WebSocket + async with websockets.connect(self.gateway_url) as ws: + await self.logger.info(f'Connected to KOOK WebSocket: {self.gateway_url}') + self.ws = ws + + # Start heartbeat + self.heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + + # Wait for HELLO within 6 seconds + try: + hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0) + + # Handle compressed messages (same as main message loop) + if isinstance(hello_msg, bytes): + # Decompress if compressed + try: + hello_msg = zlib.decompress(hello_msg).decode('utf-8') + except Exception: + # Not compressed or decompression failed + hello_msg = hello_msg.decode('utf-8') + + hello_data = json.loads(hello_msg) + + if hello_data.get('s') == 1: # HELLO signal + await self._handle_hello(hello_data['d']) + else: + raise Exception(f'Expected HELLO signal, got signal {hello_data.get("s")}') + except asyncio.TimeoutError: + raise Exception('Did not receive HELLO within 6 seconds') + + # Reset retry count on successful connection + retry_count = 0 + + # Main message loop + async for message in ws: + if isinstance(message, bytes): + # Decompress if compressed + try: + message = zlib.decompress(message).decode('utf-8') + except Exception: + # Not compressed or decompression failed + message = message.decode('utf-8') + + try: + msg_data = json.loads(message) + signal = msg_data.get('s') + + if signal == 0: # EVENT + data = msg_data.get('d', {}) + sn = msg_data.get('sn', 0) + await self._handle_event(data, sn) + elif signal == 3: # PONG + await self._handle_pong(msg_data.get('d', {})) + elif signal == 5: # RECONNECT + # await self.logger.info('Received RECONNECT signal') + break # Break to reconnect + elif signal == 6: # RESUME ACK + # await self.logger.info('Resume successful') + pass + except json.JSONDecodeError: + await self.logger.error(f'Failed to parse message: {message}') + except Exception as e: + await self.logger.error(f'Error processing message: {e}\n{traceback.format_exc()}') + + except websockets.exceptions.ConnectionClosed: + await self.logger.warning('KOOK WebSocket connection closed, reconnecting...') + retry_count += 1 + await asyncio.sleep(2**retry_count) # Exponential backoff + except Exception as e: + await self.logger.error(f'KOOK WebSocket error: {e}\n{traceback.format_exc()}') + retry_count += 1 + await asyncio.sleep(2**retry_count) + finally: + # Stop heartbeat + if self.heartbeat_task: + self.heartbeat_task.cancel() + try: + await self.heartbeat_task + except asyncio.CancelledError: + pass + self.ws = None + + if retry_count >= max_retries: + await self.logger.error(f'Failed to connect after {max_retries} retries') + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + """Send a message to a channel or user""" + content, msg_type = await self.message_converter.yiri2target(message) + + # Determine endpoint based on target_type + if target_type == 'GROUP': + # Send to channel + url = 'https://www.kookapp.cn/api/v3/message/create' + payload = { + 'target_id': target_id, + 'content': content, + 'type': msg_type, + } + else: # PERSON or default + # Send direct message + url = 'https://www.kookapp.cn/api/v3/direct-message/create' + payload = { + 'target_id': target_id, + 'content': content, + 'type': msg_type, + } + + headers = { + 'Authorization': f'Bot {self.config["token"]}', + 'Content-Type': 'application/json', + } + + try: + if not self.http_session: + self.http_session = httpclient.get_session() + + async with self.http_session.post(url, json=payload, headers=headers) as response: + if response.status == 200: + result = await response.json() + if result.get('code') == 0: + await self.logger.debug(f'Message sent successfully to {target_id}') + else: + await self.logger.error(f'Failed to send message: {result.get("message")}') + else: + await self.logger.error(f'Failed to send message: HTTP {response.status}') + except Exception as e: + await self.logger.error(f'Error sending message: {e}') + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + """Reply to a message""" + content, msg_type = await self.message_converter.yiri2target(message) + + kook_event = message_source.source_platform_object + channel_type = kook_event.get('channel_type') + target_id = kook_event.get('target_id') + msg_id = kook_event.get('msg_id') + + # Determine endpoint based on channel_type + if channel_type == 'GROUP': + url = 'https://www.kookapp.cn/api/v3/message/create' + payload = { + 'target_id': target_id, + 'content': content, + 'type': msg_type, + } + else: # PERSON + url = 'https://www.kookapp.cn/api/v3/direct-message/create' + # For direct messages, we need the chat_code or target_id + author_id = kook_event.get('author_id') + extra = kook_event.get('extra', {}) + chat_code = extra.get('code', '') + + payload = { + 'content': content, + 'type': msg_type, + } + + if chat_code: + payload['chat_code'] = chat_code + else: + payload['target_id'] = str(author_id) + + # Add quote if requested + if quote_origin and msg_id: + payload['quote'] = msg_id + + payload['reply_msg_id'] = msg_id + + headers = { + 'Authorization': f'Bot {self.config["token"]}', + 'Content-Type': 'application/json', + } + + try: + if not self.http_session: + self.http_session = httpclient.get_session() + + async with self.http_session.post(url, json=payload, headers=headers) as response: + if response.status == 200: + result = await response.json() + if result.get('code') == 0: + await self.logger.debug('Reply sent successfully') + else: + await self.logger.error(f'Failed to send reply: {result.get("message")}') + else: + await self.logger.error(f'Failed to send reply: HTTP {response.status}') + except Exception as e: + await self.logger.error(f'Error sending reply: {e}') + + async def is_muted(self, group_id: int) -> bool: + """Check if bot is muted in a group (not implemented for KOOK)""" + return False + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + """Register an event listener""" + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + """Unregister an event listener""" + self.listeners.pop(event_type, None) + + async def run_async(self): + """Start the KOOK adapter""" + # Debug: Track run_async + with open('/tmp/kook_adapter_run.txt', 'w') as f: + f.write(f'KOOK adapter run_async called at {time.time()}\n') + + self.running = True + + try: + # Create HTTP session + self.http_session = httpclient.get_session() + + await self.logger.info('Starting KOOK adapter') + + # Get bot's user information and set bot_account_id + try: + bot_info = await self._get_bot_user_info() + self.bot_account_id = str(bot_info.get('id', '')) + except Exception as e: + await self.logger.error(f'Failed to get bot user info: {e}') + # Continue anyway, but bot will process its own messages + + # Start WebSocket connection + self.ws_task = asyncio.create_task(self._websocket_loop()) + + # Keep running + await self.ws_task + except Exception as e: + await self.logger.error(f'KOOK adapter error: {e}\n{traceback.format_exc()}') + finally: + self.running = False + + async def kill(self) -> bool: + """Stop the KOOK adapter""" + self.running = False + + # Cancel tasks + if self.heartbeat_task: + self.heartbeat_task.cancel() + try: + await self.heartbeat_task + except asyncio.CancelledError: + pass + + if self.ws_task: + self.ws_task.cancel() + try: + await self.ws_task + except asyncio.CancelledError: + pass + + # Close WebSocket + if self.ws: + try: + await self.ws.close() + except Exception: + pass # Already closed or error during close + + # Close HTTP session + if self.http_session: + await self.http_session.close() + + await self.logger.info('KOOK adapter stopped') + return True diff --git a/src/langbot/pkg/platform/sources/kook.yaml b/src/langbot/pkg/platform/sources/kook.yaml new file mode 100644 index 0000000..c63d35e --- /dev/null +++ b/src/langbot/pkg/platform/sources/kook.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: kook + label: + en_US: KOOK + zh_Hans: KOOK + zh_Hant: KOOK + description: + en_US: KOOK Adapter (formerly KaiHeiLa) + zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息 + zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息 + icon: kook.png +spec: + categories: + - china + help_links: + zh: https://link.langbot.app/zh/platforms/kook + en: https://link.langbot.app/en/platforms/kook + ja: https://link.langbot.app/ja/platforms/kook + config: + - name: token + label: + en_US: Bot Token + zh_Hans: 机器人令牌 + zh_Hant: 機器人令牌 + type: string + required: true + default: "" +execution: + python: + path: ./kook.py + attr: KookAdapter diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py new file mode 100644 index 0000000..fdc5cb5 --- /dev/null +++ b/src/langbot/pkg/platform/sources/lark.py @@ -0,0 +1,3092 @@ +from __future__ import annotations + +import lark_oapi +from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody, CreateFileRequest, CreateFileRequestBody +import traceback +import typing +import asyncio +import re +import base64 +import uuid +import json +import time +import datetime +import hashlib +from Crypto.Cipher import AES +import tempfile +import os +import mimetypes + +from langbot.pkg.utils import httpclient +import lark_oapi.ws.exception +import quart +from lark_oapi.api.im.v1 import * +import pydantic +from lark_oapi.api.cardkit.v1 import * +from lark_oapi.api.auth.v3 import * +from lark_oapi.core.model import * + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +import langbot_plugin.api.entities.builtin.provider.session as provider_session + + +def _lark_form_component_name(prefix: str, field_name: str, index: int) -> str: + safe_name = re.sub(r'[^A-Za-z0-9_]', '_', field_name)[:8] or 'field' + digest = hashlib.sha1(field_name.encode('utf-8')).hexdigest()[:6] + return f'{prefix}_{index}_{safe_name}_{digest}'[:32] + + +def _dify_field_name(field: dict) -> str: + return str(field.get('output_variable_name') or field.get('name') or field.get('id') or '').strip() + + +def _dify_field_type(field: dict) -> str: + return str(field.get('type') or 'text').strip().lower() + + +def _dify_select_options(field: dict) -> list[str]: + source = field.get('option_source') or {} + value = source.get('value') if isinstance(source, dict) else None + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [part.strip() for part in value.splitlines() if part.strip()] + options = field.get('options') + if isinstance(options, list): + result: list[str] = [] + for item in options: + if isinstance(item, dict): + result.append(str(item.get('label') or item.get('value') or '')) + else: + result.append(str(item)) + return [item for item in result if item] + return [] + + +def _dify_default_value(field: dict) -> str: + default = field.get('default') + if isinstance(default, dict): + value = default.get('value') if default.get('type') == 'constant' or 'value' in default else '' + else: + value = default + return '' if value is None else str(value) + + +def _lark_clean_form_content(form_content: str, input_defs: list[dict]) -> str: + field_names = {_dify_field_name(field) for field in input_defs if _dify_field_name(field)} + kept_lines: list[str] = [] + for line in (form_content or '').splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1) in field_names: + continue + kept_lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) + + +def _lark_form_input_defs(form_data: dict) -> list[dict]: + return list(form_data.get('all_input_defs') or form_data.get('input_defs') or []) + + +def _lark_current_input_defs(form_data: dict) -> list[dict]: + """Return only the field that belongs to the current interactive step.""" + if form_data.get('_action_select_only'): + return [] + input_defs = list(form_data.get('input_defs') or []) + current_field = str(form_data.get('_current_input_field') or '').strip() + if not current_field: + return input_defs + return [field for field in input_defs if _dify_field_name(field) == current_field] + + +def _lark_should_update_stream_element( + *, + resume_from: bool, + form_data: dict | None, + msg_seq: int, + is_final: bool, +) -> bool: + """Return whether the still-open streaming element should be updated.""" + return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final) + + +def _lark_display_input_value(field: dict, value: typing.Any) -> str: + field_type = _dify_field_type(field) + if field_type == 'file': + if isinstance(value, dict): + return value.get('url') or value.get('upload_file_id') or '1 file' + return str(value) + if field_type == 'file-list': + if isinstance(value, list): + return f'{len(value)} file(s)' + return str(value) + if isinstance(value, dict): + if 'value' in value and value.get('value') not in (None, ''): + return str(value.get('value')) + text = value.get('text') + if isinstance(text, dict): + content = text.get('content') + if content not in (None, ''): + return str(content) + if text not in (None, ''): + return str(text) + if isinstance(value, list): + return ', '.join(_lark_display_input_value(field, item) for item in value if item not in (None, '')) + return str(value) + + +def _lark_visible_form_content(form_data: dict) -> str: + """Return stage content with completed values interleaved for final actions.""" + source_content = form_data.get('form_content') or '' + if form_data.get('_action_select_only'): + source_content = form_data.get('raw_form_content') or source_content + + fields = { + _dify_field_name(field): field for field in _lark_form_input_defs(form_data) if _dify_field_name(field) + } + inputs = form_data.get('inputs') or {} + + def replace_placeholder(match: re.Match[str]) -> str: + field_name = match.group(1).strip() + field = fields.get(field_name) + if not field or inputs.get(field_name) in (None, '', []): + return '' + lines = _lark_completed_input_lines( + { + 'input_defs': [field], + 'inputs': {field_name: inputs[field_name]}, + } + ) + return lines[0] if lines else '' + + source_content = re.sub( + r'\{\{#\$output\.([^#{}]+)#\}\}', + replace_placeholder, + str(source_content), + ) + return _lark_clean_form_content( + str(source_content), + _lark_form_input_defs(form_data), + ) + + +def _lark_completed_input_lines(form_data: dict) -> list[str]: + inputs = form_data.get('inputs') or {} + if not isinstance(inputs, dict): + return [] + + lines: list[str] = [] + for field in _lark_form_input_defs(form_data): + field_name = _dify_field_name(field) + if not field_name: + continue + value = inputs.get(field_name) + if value in (None, '', []): + continue + display_value = _lark_display_input_value(field, value) + lines.append(f'✅ {field_name}:{display_value}') + return lines + + +def _lark_mapping_from_value(value: typing.Any) -> dict: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except (json.JSONDecodeError, TypeError): + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _lark_action_attr(action: typing.Any, name: str) -> typing.Any: + if isinstance(action, dict): + return action.get(name) + return getattr(action, name, None) + + +def _lark_extract_action_form_inputs(action: typing.Any, action_value_obj: dict) -> dict: + input_name_map = action_value_obj.get('input_name_map', {}) + if not isinstance(input_name_map, dict): + input_name_map = {} + + form_value = _lark_mapping_from_value(_lark_action_attr(action, 'form_value')) + if not form_value: + for key in ('form_value', 'formValue', 'form_values', 'formValues'): + form_value = _lark_mapping_from_value(action_value_obj.get(key)) + if form_value: + break + + if not form_value: + action_name = _lark_action_attr(action, 'name') + input_value = _lark_action_attr(action, 'input_value') + option_value = _lark_action_attr(action, 'option') + if action_name and input_value not in (None, ''): + form_value = {action_name: input_value} + elif action_name and option_value not in (None, ''): + form_value = {action_name: option_value} + + form_inputs = {} + for component_name, value in form_value.items(): + field_name = input_name_map.get(component_name) + if not field_name and isinstance(component_name, str) and '.' in component_name: + field_name = input_name_map.get(component_name.rsplit('.', 1)[-1], component_name) + if not field_name: + field_name = component_name + if field_name and value not in (None, '', []): + form_inputs[str(field_name)] = value + return form_inputs + + +class AESCipher(object): + def __init__(self, key): + self.bs = AES.block_size + self.key = hashlib.sha256(AESCipher.str_to_bytes(key)).digest() + + @staticmethod + def str_to_bytes(data): + u_type = type(b''.decode('utf8')) + if isinstance(data, u_type): + return data.encode('utf8') + return data + + @staticmethod + def _unpad(s): + return s[: -ord(s[len(s) - 1 :])] + + def decrypt(self, enc): + iv = enc[: AES.block_size] + cipher = AES.new(self.key, AES.MODE_CBC, iv) + return self._unpad(cipher.decrypt(enc[AES.block_size :])) + + def decrypt_string(self, enc): + enc = base64.b64decode(enc) + return self.decrypt(enc).decode('utf8') + + +class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> typing.Optional[str]: + """Upload an image to Lark and return the image_key, or None if upload fails.""" + image_bytes = None + + if msg.base64: + try: + # Remove data URL prefix if present + base64_data = msg.base64 + if base64_data.startswith('data:'): + base64_data = base64_data.split(',', 1)[1] + image_bytes = base64.b64decode(base64_data) + except Exception as e: + print(f'Failed to decode base64 image: {e}') + traceback.print_exc() + return None + elif msg.url: + try: + session = httpclient.get_session() + async with session.get(msg.url) as response: + if response.status == 200: + image_bytes = await response.read() + else: + print(f'Failed to download image from {msg.url}: HTTP {response.status}') + return None + except Exception as e: + print(f'Failed to download image from {msg.url}: {e}') + traceback.print_exc() + return None + elif msg.path: + try: + with open(msg.path, 'rb') as f: + image_bytes = f.read() + except Exception as e: + print(f'Failed to read image from path {msg.path}: {e}') + traceback.print_exc() + return None + + if image_bytes is None: + print( + f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})' + ) + return None + + try: + # Create a temporary file to store the image bytes + import tempfile + import os + + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(image_bytes) + temp_file.flush() + temp_file_path = temp_file.name + + try: + # Create image request using the temporary file + request = ( + CreateImageRequest.builder() + .request_body( + CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build() + ) + .build() + ) + + response = await api_client.im.v1.image.acreate(request) + + if not response.success(): + print( + f'client.im.v1.image.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}' + ) + return None + + return response.data.image_key + finally: + # Clean up the temporary file + os.unlink(temp_file_path) + except Exception as e: + print(f'Failed to upload image to Lark: {e}') + traceback.print_exc() + return None + + @staticmethod + async def upload_file_to_lark( + file_bytes: bytes, + api_client: lark_oapi.Client, + file_type: str, + file_name: str = 'file', + duration: typing.Optional[int] = None, + ) -> typing.Optional[str]: + """Upload a file to Lark and return the file_key, or None if upload fails. + + Args: + file_bytes: Raw file bytes. + api_client: Lark API client. + file_type: Lark file type, e.g. 'opus', 'mp4', 'pdf', 'doc', etc. + file_name: Display name for the file. + duration: Duration in milliseconds (for audio files). + """ + try: + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(file_bytes) + temp_file_path = temp_file.name + + try: + body_builder = ( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(file_name) + .file(open(temp_file_path, 'rb')) + ) + if duration is not None: + body_builder = body_builder.duration(duration) + + request = CreateFileRequest.builder().request_body(body_builder.build()).build() + + response = await api_client.im.v1.file.acreate(request) + + if not response.success(): + print( + f'client.im.v1.file.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}' + ) + return None + + return response.data.file_key + finally: + os.unlink(temp_file_path) + except Exception as e: + print(f'Failed to upload file to Lark: {e}') + traceback.print_exc() + return None + + @staticmethod + async def _get_media_bytes( + msg: typing.Union[platform_message.Voice, platform_message.File], + ) -> typing.Optional[bytes]: + """Get bytes from a Voice or File message (base64, url, or path).""" + data = None + + if msg.base64: + try: + base64_str = msg.base64 + if ',' in base64_str: + base64_str = base64_str.split(',', 1)[1] + data = base64.b64decode(base64_str) + except Exception: + pass + elif msg.url: + try: + session = httpclient.get_session() + async with session.get(msg.url) as resp: + if resp.status == 200: + data = await resp.read() + except Exception: + pass + elif msg.path: + try: + with open(msg.path, 'rb') as f: + data = f.read() + except Exception: + pass + + return data + + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain, api_client: lark_oapi.Client + ) -> typing.Tuple[list, list]: + """Convert message chain to Lark format. + + Returns: + Tuple of (text_elements, image_keys): + - text_elements: List of paragraphs for post message format + - media_items: List of dicts with 'msg_type' and 'content' for separate media messages + """ + message_elements = [] + media_items = [] + pending_paragraph = [] + + # Regex pattern to match Markdown image syntax: ![alt](url) + markdown_image_pattern = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)') + + async def process_text_with_images(text: str) -> typing.Tuple[str, list]: + """Extract Markdown images from text and return cleaned text + image URLs.""" + extracted_urls = [] + + # Find all Markdown images + matches = list(markdown_image_pattern.finditer(text)) + if not matches: + return text, [] + + # Extract URLs and remove image syntax from text + cleaned_text = text + for match in reversed(matches): # Reverse to maintain correct positions + url = match.group(2) + extracted_urls.insert(0, url) # Insert at beginning since we're going in reverse + # Replace image syntax with empty string or a placeholder + cleaned_text = cleaned_text[: match.start()] + cleaned_text[match.end() :] + + # Clean up multiple consecutive newlines that might result from removing images + cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text) + cleaned_text = cleaned_text.strip() + + return cleaned_text, extracted_urls + + for msg in message_chain: + if isinstance(msg, platform_message.Plain): + # Ensure text is valid UTF-8 + try: + text = msg.text.encode('utf-8').decode('utf-8') + except UnicodeError: + try: + text = msg.text.encode('latin1').decode('utf-8') + except UnicodeError: + text = msg.text.encode('utf-8', errors='replace').decode('utf-8') + + # Check for and extract Markdown images from text + cleaned_text, extracted_urls = await process_text_with_images(text) + + # Split by blank lines to create separate paragraphs for Lark post format. + # Lark truncates md elements at the first \n\n, so we must use the + # post format's native paragraph structure instead. + if cleaned_text: + segments = re.split(r'\n\s*\n', cleaned_text) + for i, segment in enumerate(segments): + segment = segment.strip() + if not segment: + continue + if i > 0 and pending_paragraph: + message_elements.append(pending_paragraph) + pending_paragraph = [] + pending_paragraph.append({'tag': 'md', 'text': segment}) + + # Process extracted image URLs + for url in extracted_urls: + temp_image = platform_message.Image(url=url) + image_key = await LarkMessageConverter.upload_image_to_lark(temp_image, api_client) + if image_key: + media_items.append({'msg_type': 'image', 'content': {'image_key': image_key}}) + + elif isinstance(msg, platform_message.At): + pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []}) + elif isinstance(msg, platform_message.AtAll): + pending_paragraph.append({'tag': 'at', 'user_id': 'all', 'style': []}) + elif isinstance(msg, platform_message.Image): + image_key = await LarkMessageConverter.upload_image_to_lark(msg, api_client) + if image_key: + media_items.append({'msg_type': 'image', 'content': {'image_key': image_key}}) + elif isinstance(msg, platform_message.Voice): + data = await LarkMessageConverter._get_media_bytes(msg) + if data: + duration = int(msg.length * 1000) if msg.length else None + file_key = await LarkMessageConverter.upload_file_to_lark( + data, api_client, file_type='opus', file_name='voice.opus', duration=duration + ) + if file_key: + media_items.append({'msg_type': 'audio', 'content': {'file_key': file_key}}) + elif isinstance(msg, platform_message.File): + data = await LarkMessageConverter._get_media_bytes(msg) + if data: + file_name = msg.name or 'file' + # Guess file_type from extension + ext = os.path.splitext(file_name)[1].lstrip('.').lower() if file_name else '' + file_type_map = { + 'opus': 'opus', + 'mp4': 'mp4', + 'pdf': 'pdf', + 'doc': 'doc', + 'docx': 'doc', + 'xls': 'xls', + 'xlsx': 'xls', + 'ppt': 'ppt', + 'pptx': 'ppt', + } + file_type = file_type_map.get(ext, 'stream') + file_key = await LarkMessageConverter.upload_file_to_lark( + data, api_client, file_type=file_type, file_name=file_name + ) + if file_key: + media_items.append({'msg_type': 'file', 'content': {'file_key': file_key}}) + elif isinstance(msg, platform_message.Forward): + for node in msg.node_list: + sub_elements, sub_media = await LarkMessageConverter.yiri2target(node.message_chain, api_client) + message_elements.extend(sub_elements) + media_items.extend(sub_media) + + if pending_paragraph: + message_elements.append(pending_paragraph) + + return message_elements, media_items + + @staticmethod + async def target2yiri( + message: lark_oapi.api.im.v1.model.event_message.EventMessage, + api_client: lark_oapi.Client, + ) -> platform_message.MessageChain: + message_content = json.loads(message.content) + + lb_msg_list = [] + + msg_create_time = datetime.datetime.fromtimestamp(int(message.create_time) / 1000) + + lb_msg_list.append(platform_message.Source(id=message.message_id, time=msg_create_time)) + + if message.message_type == 'text': + element_list = [] + + def text_element_recur(text_ele: dict) -> list[dict]: + if text_ele['text'] == '': + return [] + + at_pattern = re.compile(r'@_user_[\d]+') + at_matches = at_pattern.findall(text_ele['text']) + + name_mapping = {} + for mathc in at_matches: + for mention in message.mentions: + if mention.key == mathc: + name_mapping[mathc] = mention.name + break + + if len(name_mapping.keys()) == 0: + return [text_ele] + + # 只处理第一个,剩下的递归处理 + text_split = text_ele['text'].split(list(name_mapping.keys())[0]) + + new_list = [] + + left_text = text_split[0] + right_text = text_split[1] + + new_list.extend(text_element_recur({'tag': 'text', 'text': left_text, 'style': []})) + + new_list.append( + { + 'tag': 'at', + 'user_id': list(name_mapping.keys())[0], + 'user_name': name_mapping[list(name_mapping.keys())[0]], + 'style': [], + } + ) + + new_list.extend(text_element_recur({'tag': 'text', 'text': right_text, 'style': []})) + + return new_list + + element_list = text_element_recur({'tag': 'text', 'text': message_content['text'], 'style': []}) + + message_content = {'title': '', 'content': element_list} + + elif message.message_type == 'post': + new_list = [] + + for ele in message_content['content']: + if type(ele) is dict: + new_list.append(ele) + elif type(ele) is list: + new_list.extend(ele) + + message_content['content'] = new_list + elif message.message_type == 'image': + message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}] + elif message.message_type == 'file': + message_content['content'] = [ + {'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']} + ] + elif message.message_type == 'audio': + message_content['content'] = [ + { + 'tag': 'audio', + 'file_key': message_content['file_key'], + 'duration': message_content.get('duration', 0), + } + ] + + for ele in message_content['content']: + if ele['tag'] == 'text': + lb_msg_list.append(platform_message.Plain(text=ele['text'])) + elif ele['tag'] == 'at': + lb_msg_list.append(platform_message.At(target=ele['user_name'])) + elif ele['tag'] == 'img': + image_key = ele['image_key'] + + request: GetMessageResourceRequest = ( + GetMessageResourceRequest.builder() + .message_id(message.message_id) + .file_key(image_key) + .type('image') + .build() + ) + + response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request) + + if not response.success(): + raise Exception( + f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + image_bytes = response.file.read() + image_base64 = base64.b64encode(image_bytes).decode() + + image_format = response.raw.headers['content-type'] + + lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}')) + elif ele['tag'] == 'audio': + file_key = ele['file_key'] + duration = ele['duration'] + + # Download audio file + request: GetMessageResourceRequest = ( + GetMessageResourceRequest.builder() + .message_id(message.message_id) + .file_key(file_key) + .type('file') + .build() + ) + + try: + response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request) + + if not response.success(): + print(f'Failed to download audio: code: {response.code}, msg: {response.msg}') + lb_msg_list.append(platform_message.Plain(text='[Audio file download failed]')) + return platform_message.MessageChain(lb_msg_list) + + # Read audio bytes + audio_bytes = response.file.read() + audio_base64 = base64.b64encode(audio_bytes).decode() + + # Get content type from response headers + content_type = response.raw.headers.get('content-type', 'audio/mpeg') + + mime_main = content_type.split(';')[0].strip() + ext = mimetypes.guess_extension(mime_main) or '.bin' + temp_dir = tempfile.gettempdir() + temp_file_path = os.path.join(temp_dir, f'lark_audio_{file_key}{ext}') + + with open(temp_file_path, 'wb') as f: + f.write(audio_bytes) + + # Create Voice message: prefer path/url + length, include base64 as optional data URI + lb_msg_list.append( + platform_message.Voice( + voice_id=file_key, + url=f'file://{temp_file_path}', + path=temp_file_path, + base64=f'data:{content_type};base64,{audio_base64}', + length=(duration // 1000) if duration else None, + ) + ) + except Exception as e: + print(f'Error downloading audio: {e}') + traceback.print_exc() + lb_msg_list.append(platform_message.Plain(text='[Audio file download error]')) + + elif ele['tag'] == 'file': + file_key = ele['file_key'] + file_name = ele['file_name'] + + request: GetMessageResourceRequest = ( + GetMessageResourceRequest.builder() + .message_id(message.message_id) + .file_key(file_key) + .type('file') + .build() + ) + + response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request) + + if not response.success(): + raise Exception( + f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + file_bytes = response.file.read() + file_base64 = base64.b64encode(file_bytes).decode() + + file_format = response.raw.headers['content-type'] + + file_size = len(file_bytes) + + # Determine extension from content-type if possible + content_type = response.raw.headers.get('content-type', '') + mime_main = content_type.split(';')[0].strip() if content_type else '' + ext = mimetypes.guess_extension(mime_main) or '' + + # Ensure a safe filename (avoid path components) + safe_name = os.path.basename(file_name).replace('/', '_').replace('\\', '_') + if ext and not safe_name.lower().endswith(ext.lower()): + filename_with_ext = f'{safe_name}{ext}' + else: + filename_with_ext = safe_name + + temp_dir = tempfile.gettempdir() + temp_file_path = os.path.join(temp_dir, f'lark_{file_key}_{filename_with_ext}') + + with open(temp_file_path, 'wb') as f: + f.write(file_bytes) + + # Create File message with local path and file:// URL + lb_msg_list.append( + platform_message.File( + id=file_key, + name=file_name, + size=file_size, + url=f'file://{temp_file_path}', + path=temp_file_path, + base64=f'data:{file_format};base64,{file_base64}', # not including base64 by default to save memory; can be added if needed + ) + ) + + return platform_message.MessageChain(lb_msg_list) + + +class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter): + _processed_thread_quote_cache: typing.ClassVar[dict[str, float]] = {} + _processed_thread_quote_cache_max_size: typing.ClassVar[int] = 4096 + _processed_thread_quote_cache_ttl_seconds: typing.ClassVar[int] = 86400 + + @classmethod + def _prune_processed_thread_quote_cache(cls, now: typing.Optional[float] = None) -> None: + if now is None: + now = time.time() + + expire_before = now - cls._processed_thread_quote_cache_ttl_seconds + while cls._processed_thread_quote_cache: + oldest_key, oldest_ts = next(iter(cls._processed_thread_quote_cache.items())) + if oldest_ts >= expire_before: + break + cls._processed_thread_quote_cache.pop(oldest_key, None) + + while len(cls._processed_thread_quote_cache) > cls._processed_thread_quote_cache_max_size: + oldest_key = next(iter(cls._processed_thread_quote_cache)) + cls._processed_thread_quote_cache.pop(oldest_key, None) + + @classmethod + def _mark_thread_quote_processed(cls, thread_id: str) -> None: + now = time.time() + cls._prune_processed_thread_quote_cache(now) + cls._processed_thread_quote_cache[thread_id] = now + + @classmethod + def _extract_quote_message_id(cls, message: EventMessage) -> typing.Optional[str]: + """ + Extract the message ID to quote from the given message. + + Rules: + - First thread reply in a topic: return parent_id and mark topic as processed + - Follow-up thread replies in the same topic: return None + - Non-thread message: return parent_id if valid (non-empty, different from message_id) + + Thread reply state is kept in a bounded TTL cache to avoid unbounded memory growth. + """ + parent_id = getattr(message, 'parent_id', None) + if not parent_id: + return None + + message_id = getattr(message, 'message_id', None) + if parent_id == message_id: + return None + + thread_id = getattr(message, 'thread_id', None) + if thread_id: + cls._prune_processed_thread_quote_cache() + if thread_id in cls._processed_thread_quote_cache: + return None + cls._mark_thread_quote_processed(thread_id) + + return parent_id + + @staticmethod + def _build_event_message_from_message_item(message_item: Message) -> typing.Optional[EventMessage]: + """ + Build EventMessage from SDK typed Message item. + + Returns None if body or content is missing. + """ + body = getattr(message_item, 'body', None) + if not body: + return None + + content = getattr(body, 'content', None) + if not content: + return None + + event_data = { + 'message_id': message_item.message_id, + 'message_type': message_item.msg_type, + 'content': content, + 'create_time': message_item.create_time, + 'mentions': getattr(message_item, 'mentions', []) or [], + } + + # Preserve thread-related fields + if hasattr(message_item, 'parent_id') and message_item.parent_id: + event_data['parent_id'] = message_item.parent_id + if hasattr(message_item, 'root_id') and message_item.root_id: + event_data['root_id'] = message_item.root_id + if hasattr(message_item, 'thread_id') and message_item.thread_id: + event_data['thread_id'] = message_item.thread_id + if hasattr(message_item, 'chat_id') and message_item.chat_id: + event_data['chat_id'] = message_item.chat_id + + return EventMessage(event_data) + + @staticmethod + async def _fetch_quoted_message( + quote_message_id: str, + api_client: lark_oapi.Client, + ) -> typing.Optional[platform_message.MessageChain]: + """ + Fetch the quoted message and convert to MessageChain. + + Returns None if: + - API call fails + - Response items is empty + - Message item normalization fails + """ + request = GetMessageRequest.builder().message_id(quote_message_id).build() + response = await api_client.im.v1.message.aget(request) + + if not response.success(): + return None + + items = getattr(response.data, 'items', None) + if not items: + return None + + message_item = items[0] + event_message = LarkEventConverter._build_event_message_from_message_item(message_item) + if event_message is None: + return None + + quote_chain = await LarkMessageConverter.target2yiri(event_message, api_client) + return quote_chain + + @staticmethod + async def yiri2target( + event: platform_events.MessageEvent, + ) -> lark_oapi.im.v1.P2ImMessageReceiveV1: + pass + + @staticmethod + async def target2yiri( + event: lark_oapi.im.v1.P2ImMessageReceiveV1, api_client: lark_oapi.Client + ) -> platform_events.Event: + message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client) + + # Check for quote/reply message + # Extract files/images/voice from quote and add them as top-level components + # so that plugins like FileReader can process them the same way as direct messages + quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message) + if quote_message_id: + quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client) + if quote_chain: + # Filter out Source component from quoted chain, keep only content + quote_components = [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)] + + # Add quoted content as top-level components instead of wrapping in Quote + for comp in quote_components: + if isinstance(comp, platform_message.File): + # Add file as top-level component (same as direct message) + message_chain.append(comp) + elif isinstance(comp, platform_message.Image): + # Add image as top-level component + message_chain.append(comp) + elif isinstance(comp, platform_message.Voice): + # Add voice as top-level component + message_chain.append(comp) + elif isinstance(comp, platform_message.Plain): + # Add text with context prefix + message_chain.append(platform_message.Plain(text=f'[引用消息] {comp.text}')) + + if event.event.message.chat_type == 'p2p': + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.event.sender.sender_id.open_id, + nickname=event.event.sender.sender_id.union_id, + remark='', + ), + message_chain=message_chain, + time=event.event.message.create_time, + source_platform_object=event, + ) + elif event.event.message.chat_type == 'group': + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=event.event.sender.sender_id.open_id, + member_name=event.event.sender.sender_id.union_id, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=event.event.message.chat_id, + name='', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=event.event.message.create_time, + source_platform_object=event, + ) + + +CARD_ID_CACHE_SIZE = 500 +CARD_ID_CACHE_MAX_LIFETIME = 20 * 60 # 20分钟 + + +class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + bot: lark_oapi.ws.Client = pydantic.Field(exclude=True) + api_client: lark_oapi.Client = pydantic.Field(exclude=True) + ap: typing.Any = pydantic.Field(exclude=True, default=None) + + bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识 + lark_tenant_key: str = pydantic.Field(exclude=True, default='') # 飞书企业key + + message_converter: LarkMessageConverter = LarkMessageConverter() + event_converter: LarkEventConverter = LarkEventConverter() + cipher: AESCipher + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] + + quart_app: quart.Quart = pydantic.Field(exclude=True) + + card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片 + + # Monitoring message ID mapping for feedback correlation + # Temp: user Lark message ID → monitoring_message_id (populated by on_monitoring_message_created, consumed by create_message_card) + pending_monitoring_msg: dict[str, str] + # Final: reply Lark message ID → (monitoring_message_id, timestamp) (used by feedback callbacks) + reply_to_monitoring_msg: dict[str, tuple[str, float]] + reply_message_card_ids: dict[str, str] + card_sequence_dict: dict[str, int] + # card_id → set of source message ids registered against it (for cleanup) + card_id_to_source_ids: dict[str, set[str]] + # card_id → current streaming_txt content cache (needed for full aupdate during resume transition) + card_streaming_text: dict[str, str] + # card_id → pre-pause streaming_txt text (captured when resume first chunk arrives) + card_pre_pause_text: dict[str, str] + # card_id → form_content captured when the form is first shown (for resume notice) + card_form_content: dict[str, str] + # card_id → input_defs / inputs captured for the selected-action notice + card_form_input_defs: dict[str, list[dict]] + card_form_inputs: dict[str, dict] + # set of card_ids that have already transitioned from "buttons visible" to "resume layout" + card_resume_transitioned: set[str] + _MONITORING_MAPPING_TTL = 600 # 10 minutes + + seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识 + bot_uuid: str = None # 机器人UUID + app_ticket: str = None # 商店应用用到 + app_access_token: str = None # 商店应用用到 + app_access_token_expire_at: int = None + tenant_access_tokens: dict[str, dict[str, str]] = {} # 租户access_token映射 + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): + quart_app = quart.Quart(__name__) + + async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1): + lb_event = await self.event_converter.target2yiri(event, self.api_client) + + await self.listeners[type(lb_event)](lb_event, self) + + def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1): + asyncio.create_task(on_message(event)) + + def schedule_on_app_loop(coro): + """Run a coroutine on the application event loop from sync callbacks.""" + return asyncio.run_coroutine_threadsafe(coro, self.ap.event_loop) + + def sync_on_card_action(event): + try: + action_value_raw = getattr(getattr(event.event, 'action', None), 'value', {}) + # Parse JSON string values (from form action buttons) + action_value_obj = _lark_mapping_from_value(action_value_raw) + action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else '' + + # Handle Dify form action button clicks + if isinstance(action_value_obj, dict) and action_value_obj.get('form_action'): + form_token = action_value_obj.get('form_token', '') + workflow_run_id = action_value_obj.get('workflow_run_id', '') + action_id = action_value_obj.get('action_id', '') + session_key = action_value_obj.get('session_key', '') + action = getattr(event.event, 'action', None) + form_inputs = _lark_extract_action_form_inputs(action, action_value_obj) + + if session_key.startswith('group_') or session_key.startswith('g:'): + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('g:') + else session_key[len('group_') :] + ) + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('p:') + else session_key[len('person_') :] + ) + + # Find the bot entity to get bot_uuid and pipeline_uuid + bot_uuid = '' + pipeline_uuid = action_value_obj.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + form_action_data = { + 'form_token': form_token, + 'workflow_run_id': workflow_run_id, + 'action_id': action_id, + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': form_inputs, + } + if action_value_obj.get('_input_progress'): + form_action_data['_input_progress'] = True + + context = getattr(event.event, 'context', None) + open_message_id = getattr(context, 'open_message_id', None) + if open_message_id and form_inputs: + card_id = self.reply_message_card_ids.get(str(open_message_id)) + else: + card_id = None + if not card_id: + card_id = str(action_value_obj.get('card_id') or '') + if card_id and form_inputs: + cached_inputs = dict(self.card_form_inputs.get(card_id) or {}) + cached_inputs.update(form_inputs) + self.card_form_inputs[card_id] = cached_inputs + if self.ap is not None: + self.ap.logger.info( + f'Lark form action inputs cached: card_id={card_id} ' + f'open_message_id={open_message_id} keys={list(form_inputs.keys())}' + ) + source_time = datetime.datetime.now() + event_time = source_time.timestamp() + action_text = action_value_obj.get('action_id', 'confirm') + message_chain = platform_message.MessageChain( + [platform_message.Plain(text=f'[Form Action: {action_text}]')] + ) + if open_message_id: + message_chain.insert( + 0, + platform_message.Source( + id=open_message_id, + time=source_time, + ), + ) + + operator = getattr(event.event, 'operator', None) + user_id = ( + getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None) or str(launcher_id) + ) + + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + ), + message_chain=message_chain, + time=event_time, + source_platform_object=event, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=event_time, + source_platform_object=event, + ) + + async def add_form_action_query(): + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=user_id, + message_event=synthetic_event, + message_chain=message_chain, + adapter=self, + pipeline_uuid=pipeline_uuid, + variables={ + '_dify_form_action': form_action_data, + '_routed_by_rule': True, + }, + ) + + schedule_on_app_loop(add_form_action_query()) + + from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse + + return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}}) + + if action_value == '有帮助': + feedback_type = 1 + elif action_value == '无帮助': + feedback_type = 2 + else: + from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse + + return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}}) + + operator = getattr(event.event, 'operator', None) + context = getattr(event.event, 'context', None) + + user_id = getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None) + open_chat_id = getattr(context, 'open_chat_id', None) + open_message_id = getattr(context, 'open_message_id', None) + + if open_chat_id: + session_id = f'group_{open_chat_id}' + elif user_id: + session_id = f'person_{user_id}' + else: + session_id = None + + # Resolve monitoring message ID from reply message mapping + monitoring_msg_id = None + if open_message_id and open_message_id in self.reply_to_monitoring_msg: + monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0] + + feedback_event = platform_events.FeedbackEvent( + feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())), + feedback_type=feedback_type, + feedback_content=action_value, + user_id=user_id, + session_id=session_id, + message_id=open_message_id, + stream_id=monitoring_msg_id, + source_platform_object=event, + ) + + if platform_events.FeedbackEvent in self.listeners: + schedule_on_app_loop(self.listeners[platform_events.FeedbackEvent](feedback_event, self)) + + from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse + + return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}}) + except Exception: + traceback.print_exc() + schedule_on_app_loop(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')) + from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse + + return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}}) + + event_handler = ( + lark_oapi.EventDispatcherHandler.builder('', '') + .register_p2_im_message_receive_v1(sync_on_message) + .register_p2_card_action_trigger(sync_on_card_action) + .build() + ) + + bot_account_id = config['bot_name'] + + domain = self._resolve_domain(config) + bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler, domain=domain) + api_client = self.build_api_client(config) + cipher = AESCipher(config.get('encrypt-key', '')) + self.request_app_ticket(api_client, config) + + super().__init__( + config=config, + logger=logger, + lark_tenant_key=config.get('lark_tenant_key', ''), + card_id_dict={}, + pending_monitoring_msg={}, + reply_to_monitoring_msg={}, + reply_message_card_ids={}, + card_sequence_dict={}, + card_id_to_source_ids={}, + card_streaming_text={}, + card_pre_pause_text={}, + card_form_content={}, + card_form_input_defs={}, + card_form_inputs={}, + card_resume_transitioned=set(), + seq=1, + listeners={}, + quart_app=quart_app, + bot=bot, + api_client=api_client, + bot_account_id=bot_account_id, + cipher=cipher, + **kwargs, + ) + + def request_app_ticket(self, api_client, config): + app_id = config['app_id'] + app_secret = config['app_secret'] + print(f'Requesting app ticket for app_id: {app_id[:3]}***{app_id[-3:]}') + if 'isv' == config.get('app_type', 'self'): + request: ResendAppTicketRequest = ( + ResendAppTicketRequest.builder() + .request_body(ResendAppTicketRequestBody.builder().app_id(app_id).app_secret(app_secret).build()) + .build() + ) + response: ResendAppTicketResponse = api_client.auth.v3.app_ticket.resend(request) + if not response.success(): + raise Exception( + f'client.auth.v3.auth.app_ticket_resend failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + def request_app_access_token(self): + app_id = self.config['app_id'] + app_secret = self.config['app_secret'] + if 'isv' == self.config.get('app_type', 'self'): + request: CreateAppAccessTokenRequest = ( + CreateAppAccessTokenRequest.builder() + .request_body( + CreateAppAccessTokenRequestBody.builder() + .app_id(app_id) + .app_secret(app_secret) + .app_ticket(self.app_ticket) + .build() + ) + .build() + ) + response: CreateAppAccessTokenResponse = self.api_client.auth.v3.app_access_token.create(request) + if not response.success(): + raise Exception( + f'client.auth.v3.auth.app_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + content = json.loads(response.raw.content) + self.app_access_token = content['app_access_token'] + self.app_access_token_expire_at = int(time.time()) + content['expire'] - 300 + + def get_app_access_token(self): + if 'isv' != self.config.get('app_type', 'self'): + return None + if ( + self.app_access_token is None + or self.app_access_token_expire_at is None + or int(time.time()) >= self.app_access_token_expire_at + ): + self.request_app_access_token() + return self.app_access_token + + def request_tenant_access_token(self, tenant_key: str): + app_access_token = self.get_app_access_token() + if 'isv' == self.config.get('app_type', 'self'): + request: CreateTenantAccessTokenRequest = ( + CreateTenantAccessTokenRequest.builder() + .request_body( + CreateTenantAccessTokenRequestBody.builder() + .app_access_token(app_access_token) + .tenant_key(tenant_key) + .build() + ) + .build() + ) + response: CreateTenantAccessTokenResponse = self.api_client.auth.v3.tenant_access_token.create(request) + if not response.success(): + raise Exception( + f'client.auth.v3.auth.tenant_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + content = json.loads(response.raw.content) + tenant_access_token = content['tenant_access_token'] + expire = content['expire'] + self.tenant_access_tokens[tenant_key] = { + 'token': tenant_access_token, + 'expire_at': int(time.time()) + expire - 300, + } + + def get_tenant_access_token(self, tenant_key: str): + if tenant_key is None or 'isv' != self.config.get('app_type', 'self'): + return None + tenant_access_token = self.tenant_access_tokens.get(tenant_key) + if tenant_access_token is None or int(time.time()) >= tenant_access_token['expire_at']: + self.request_tenant_access_token(tenant_key) + return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None + + def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None: + """ + Get topic-scoped launcher_id for thread-aware session isolation. + + For group thread messages, returns "{group_id}_{thread_id}" + to ensure conversation context stays stable per topic. + + Returns None for non-thread messages or P2P messages. + """ + source_event = getattr(event.source_platform_object, 'event', None) + if not source_event: + return None + + message = getattr(source_event, 'message', None) + if not message: + return None + + thread_id = getattr(message, 'thread_id', None) + if not thread_id: + return None + + if isinstance(event, platform_events.GroupMessage): + return f'{event.group.id}_{thread_id}' + + return None + + @staticmethod + def _resolve_domain(config) -> str: + domain = config.get('domain', lark_oapi.FEISHU_DOMAIN) + if domain == 'custom': + domain = config.get('custom_domain', '') + if not domain: + raise ValueError('Custom domain is required when domain is set to "custom"') + return domain.rstrip('/') + + def build_api_client(self, config): + app_id = config['app_id'] + app_secret = config['app_secret'] + domain = self._resolve_domain(config) + api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).domain(domain).build() + if 'isv' == config.get('app_type', 'self'): + api_client = ( + lark_oapi.Client.builder() + .app_id(app_id) + .app_secret(app_secret) + .app_type(lark_oapi.AppType.ISV) + .domain(domain) + .build() + ) + return api_client + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) + + # Map standard target_type to Feishu receive_id_type + if target_type == 'person': + receive_id_type = 'open_id' + elif target_type == 'group': + receive_id_type = 'chat_id' + else: + receive_id_type = target_type + + # Send text message if there are text elements + if text_elements: + needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph) + + if needs_post: + msg_type = 'post' + final_content = json.dumps( + { + 'zh_Hans': { + 'title': '', + 'content': text_elements, + }, + } + ) + else: + msg_type = 'text' + parts = [] + for paragraph in text_elements: + para_text = ''.join(ele.get('text', '') for ele in paragraph) + if para_text: + parts.append(para_text) + final_content = json.dumps({'text': '\n\n'.join(parts)}) + + request: CreateMessageRequest = ( + CreateMessageRequest.builder() + .receive_id_type(receive_id_type) + .request_body( + CreateMessageRequestBody.builder() + .receive_id(target_id) + .content(final_content) + .msg_type(msg_type) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + + app_access_token = self.get_app_access_token() + req_opt: RequestOption = ( + RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build() + ) + response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt) + + if not response.success(): + raise Exception( + f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + # Send media messages separately (image, audio, file, etc.) + for media in media_items: + request: CreateMessageRequest = ( + CreateMessageRequest.builder() + .receive_id_type(receive_id_type) + .request_body( + CreateMessageRequestBody.builder() + .receive_id(target_id) + .content(json.dumps(media['content'])) + .msg_type(media['msg_type']) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + + app_access_token = self.get_app_access_token() + req_opt: RequestOption = ( + RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build() + ) + response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt) + + if not response.success(): + raise Exception( + f'client.im.v1.message.create ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + async def is_stream_output_supported(self) -> bool: + is_stream = False + if self.config.get('enable-stream-reply', None): + is_stream = True + return is_stream + + async def on_monitoring_message_created(self, query, monitoring_message_id: str): + """Called by pipeline after monitoring message is created, to map user message ID to monitoring message ID.""" + try: + user_msg_id = query.message_event.message_chain.message_id + if user_msg_id: + self.pending_monitoring_msg[user_msg_id] = monitoring_message_id + except Exception as e: + await self.logger.debug(f'Failed to map message to monitoring message: {e}') + + def _cleanup_monitoring_mapping(self): + """Remove entries older than TTL from the reply-to-monitoring mapping.""" + now = time.time() + expired = [k for k, (_, ts) in self.reply_to_monitoring_msg.items() if now - ts > self._MONITORING_MAPPING_TTL] + for k in expired: + del self.reply_to_monitoring_msg[k] + + def _next_card_sequence(self, card_id: str, suggested: int = 1) -> int: + """Return the next strictly increasing sequence for a card update.""" + current = self.card_sequence_dict.get(card_id, 0) + next_seq = max(current + 1, suggested) + self.card_sequence_dict[card_id] = next_seq + return next_seq + + def _register_card_for_source(self, card_id: str, *source_ids: str) -> None: + """Register a card_id under one or more source message ids.""" + bucket = self.card_id_to_source_ids.setdefault(card_id, set()) + for sid in source_ids: + if not sid: + continue + self.reply_message_card_ids[sid] = card_id + bucket.add(sid) + + def _drop_card_state(self, card_id: str) -> None: + """Pop all per-card state for the given card_id.""" + if not card_id: + return + for sid in self.card_id_to_source_ids.pop(card_id, set()): + self.reply_message_card_ids.pop(sid, None) + self.card_sequence_dict.pop(card_id, None) + self.card_streaming_text.pop(card_id, None) + self.card_pre_pause_text.pop(card_id, None) + self.card_form_content.pop(card_id, None) + self.card_form_input_defs.pop(card_id, None) + self.card_form_inputs.pop(card_id, None) + self.card_resume_transitioned.discard(card_id) + + async def create_card_id(self, message_id): + try: + # self.logger.debug('飞书支持stream输出,创建卡片......') + + card_data = { + 'schema': '2.0', + 'config': { + 'update_multi': True, + 'streaming_mode': True, + 'streaming_config': { + 'print_step': {'default': 1}, + 'print_frequency_ms': {'default': 70}, + 'print_strategy': 'fast', + }, + }, + 'body': { + 'direction': 'vertical', + 'padding': '12px 12px 12px 12px', + 'elements': [ + { + 'tag': 'div', + 'text': { + 'tag': 'plain_text', + 'content': 'LangBot', + 'text_size': 'normal', + 'text_align': 'left', + 'text_color': 'default', + }, + 'icon': { + 'tag': 'custom_icon', + 'img_key': 'img_v3_02p3_05c65d5d-9bad-440a-a2fb-c89571bfd5bg', + }, + }, + { + 'tag': 'markdown', + 'content': '', + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 0px 0px', + 'element_id': 'streaming_txt', + }, + { + 'tag': 'markdown', + 'content': '', + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 0px 0px', + }, + { + 'tag': 'column_set', + 'horizontal_spacing': '8px', + 'horizontal_align': 'left', + 'columns': [ + { + 'tag': 'column', + 'width': 'weighted', + 'elements': [ + { + 'tag': 'markdown', + 'content': '', + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 0px 0px', + }, + { + 'tag': 'markdown', + 'content': '', + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 0px 0px', + }, + { + 'tag': 'markdown', + 'content': '', + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 0px 0px', + }, + ], + 'padding': '0px 0px 0px 0px', + 'direction': 'vertical', + 'horizontal_spacing': '8px', + 'vertical_spacing': '2px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + 'weight': 1, + } + ], + 'margin': '0px 0px 0px 0px', + }, + {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, + { + 'tag': 'column_set', + 'horizontal_spacing': '12px', + 'horizontal_align': 'right', + 'columns': [ + { + 'tag': 'column', + 'width': 'weighted', + 'elements': [ + { + 'tag': 'markdown', + 'content': '以上内容由 AI 生成,仅供参考。更多详细、准确信息可点击引用链接查看', + 'text_align': 'left', + 'text_size': 'notation', + 'margin': '4px 0px 0px 0px', + 'icon': { + 'tag': 'standard_icon', + 'token': 'robot_outlined', + 'color': 'grey', + }, + } + ], + 'padding': '0px 0px 0px 0px', + 'direction': 'vertical', + 'horizontal_spacing': '8px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + 'weight': 1, + }, + { + 'tag': 'column', + 'width': '20px', + 'elements': [ + { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': ''}, + 'type': 'text', + 'width': 'fill', + 'size': 'medium', + 'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'}, + 'hover_tips': {'tag': 'plain_text', 'content': '有帮助'}, + 'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}], + 'margin': '0px 0px 0px 0px', + } + ], + 'padding': '0px 0px 0px 0px', + 'direction': 'vertical', + 'horizontal_spacing': '8px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + }, + { + 'tag': 'column', + 'width': '30px', + 'elements': [ + { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': ''}, + 'type': 'text', + 'width': 'default', + 'size': 'medium', + 'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'}, + 'hover_tips': {'tag': 'plain_text', 'content': '无帮助'}, + 'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}], + 'margin': '0px 0px 0px 0px', + } + ], + 'padding': '0px 0px 0px 0px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + }, + ], + 'margin': '0px 0px 4px 0px', + }, + ], + }, + } + # delay / fast 创建卡片模板,delay 延迟打印,fast 实时打印,可以自定义更好看的消息模板 + + request: CreateCardRequest = ( + CreateCardRequest.builder() + .request_body(CreateCardRequestBody.builder().type('card_json').data(json.dumps(card_data)).build()) + .build() + ) + + # 发起请求 + response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request) + + # 处理失败返回 + if not response.success(): + raise Exception( + f'client.cardkit.v1.card.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + self.card_id_dict[message_id] = response.data.card_id + + card_id = response.data.card_id + self.card_sequence_dict[card_id] = 0 + return card_id + + except Exception as e: + raise e + + async def create_message_card(self, message_id, event) -> str: + """ + 创建卡片消息。 + 使用卡片消息是因为普通消息更新次数有限制,而大模型流式返回结果可能很多而超过限制,而飞书卡片没有这个限制(api免费次数有限) + """ + # message_id = event.message_chain.message_id + + source_message_id = str(event.message_chain.message_id) + existing_card_id = self.reply_message_card_ids.get(source_message_id) + if existing_card_id: + self.card_id_dict[message_id] = existing_card_id + return True + + card_id = await self.create_card_id(message_id) + content = { + 'type': 'card', + 'data': {'card_id': card_id, 'template_variable': {'content': 'Thinking...'}}, + } # 当收到消息时发送消息模板,可添加模板变量,详情查看飞书中接口文档 + request: ReplyMessageRequest = ( + ReplyMessageRequest.builder() + .message_id(event.message_chain.message_id) + .request_body( + ReplyMessageRequestBody.builder().content(json.dumps(content)).msg_type('interactive').build() + ) + .build() + ) + tenant_key = event.source_platform_object.header.tenant_key if event.source_platform_object else None + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + # 发起请求 + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) + + # 处理失败返回 + if not response.success(): + raise Exception( + f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + # Transfer monitoring message mapping: user msg ID → reply msg ID + try: + user_msg_id = event.message_chain.message_id + reply_msg_id = getattr(response.data, 'message_id', None) + monitoring_msg_id = self.pending_monitoring_msg.pop(user_msg_id, None) + # Register the card under both the user-incoming msg id (so a + # second reply_message_first_chunk for the same user message + # reuses this card) AND the bot-reply msg id (so a synthetic + # event from a form-button callback — whose Source.id equals + # the bot's card message id — hits the same card and renders + # the resume content into it). + if reply_msg_id: + self._register_card_for_source(card_id, str(user_msg_id), str(reply_msg_id)) + else: + self._register_card_for_source(card_id, str(user_msg_id)) + if reply_msg_id and monitoring_msg_id: + self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time()) + self._cleanup_monitoring_mapping() + except Exception as e: + asyncio.create_task(self.logger.debug(f'Failed to transfer monitoring mapping in create_message_card: {e}')) + + return True + + async def _open_new_form_card( + self, + message_id: str, + message_source: platform_events.MessageEvent, + form_data: dict, + ) -> str | None: + """Spawn a fresh card to host a re-paused human-input prompt. + + Creates a new card_id (rebinding ``self.card_id_dict[message_id]``), + replies it to the current incoming message so it appears as the next + step in the chat, registers the new reply_msg_id so subsequent button + callbacks resolve back to it, and renders the prompt + buttons on it. + + Returns the new card_id, or ``None`` if creation failed (caller is + responsible for falling back to in-place update so the workflow + remains continuable). + """ + source_message_id = getattr(message_source.message_chain, 'message_id', None) + if not source_message_id: + await self.logger.error('Cannot open new form card: source message_id missing') + return None + + try: + new_card_id = await self.create_card_id(message_id) + except Exception: + await self.logger.error(f'Failed to create new form card: {traceback.format_exc()}') + return None + + tenant_key = ( + message_source.source_platform_object.header.tenant_key if message_source.source_platform_object else None + ) + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + + content = { + 'type': 'card', + 'data': {'card_id': new_card_id, 'template_variable': {'content': ''}}, + } + request: ReplyMessageRequest = ( + ReplyMessageRequest.builder() + .message_id(str(source_message_id)) + .request_body( + ReplyMessageRequestBody.builder() + .content(json.dumps(content)) + .msg_type('interactive') + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + + try: + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) + except Exception: + await self.logger.error(f'Failed to send new form card: {traceback.format_exc()}') + return None + + if not response.success(): + await self.logger.error( + f'Failed to send new form card: code={response.code}, msg={response.msg}, ' + f'log_id={response.get_log_id()}' + ) + return None + + reply_msg_id = getattr(response.data, 'message_id', None) + if reply_msg_id: + self._register_card_for_source(new_card_id, str(source_message_id), str(reply_msg_id)) + + sequence = self._next_card_sequence(new_card_id, 1) + await self._update_card_layout( + card_id=new_card_id, + message_source=message_source, + text_message='', + sequence=sequence, + form_data=form_data, + show_form_prompt=True, + ) + return new_card_id + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + # 不再需要了,因为message_id已经被包含到message_chain中 + # lark_event = await self.event_converter.yiri2target(message_source) + text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) + + # Send text message if there are text elements + if text_elements: + # Determine msg_type based on content: use 'post' if at mentions + # are present (requires post paragraph structure), otherwise 'text' + needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph) + + if needs_post: + msg_type = 'post' + final_content = json.dumps( + { + 'zh_Hans': { + 'title': '', + 'content': text_elements, + }, + } + ) + else: + msg_type = 'text' + parts = [] + for paragraph in text_elements: + para_text = ''.join(ele.get('text', '') for ele in paragraph) + if para_text: + parts.append(para_text) + final_content = json.dumps({'text': '\n\n'.join(parts)}) + + request: ReplyMessageRequest = ( + ReplyMessageRequest.builder() + .message_id(message_source.message_chain.message_id) + .request_body( + ReplyMessageRequestBody.builder() + .content(final_content) + .msg_type(msg_type) + .reply_in_thread(False) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + + tenant_key = ( + message_source.source_platform_object.header.tenant_key + if message_source.source_platform_object + else None + ) + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) + + if not response.success(): + raise Exception( + f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + # Send media messages separately (image, audio, file, etc.) + for media in media_items: + request: ReplyMessageRequest = ( + ReplyMessageRequest.builder() + .message_id(message_source.message_chain.message_id) + .request_body( + ReplyMessageRequestBody.builder() + .content(json.dumps(media['content'])) + .msg_type(media['msg_type']) + .reply_in_thread(False) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + + tenant_key = ( + message_source.source_platform_object.header.tenant_key + if message_source.source_platform_object + else None + ) + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) + + if not response.success(): + raise Exception( + f'client.im.v1.message.reply ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + """ + 回复消息变成更新卡片消息 + + Supports Dify form-action resume: when the runner yields a chunk with + ``_resume_from_form=True``, the card transitions from buttons to a + grey selection notice and a new ``streaming_txt_resume`` element is added + for subsequent resume chunks to stream into. + + When ``_open_new_card=True`` on the final chunk, the existing card is + left as-is and the pipeline will create a new card (with fresh form + buttons) for the re-pause. + """ + message_id = bot_message.resp_message_id + msg_seq = bot_message.msg_sequence + + form_data = getattr(bot_message, '_form_data', None) + resume_from = getattr(bot_message, '_resume_from_form', False) + action_title = getattr(bot_message, '_resume_action_title', '') + resume_node_title = getattr(bot_message, '_resume_node_title', '') + open_new_card = getattr(bot_message, '_open_new_card', False) + + # ── decide whether this chunk needs a card update ──────────────────── + card_id = self.card_id_dict.get(message_id) + if not card_id: + return + + if action_title: + # Build the selected notice with node_title, form_content, and action + notice_parts = [] + if resume_node_title: + notice_parts.append(f'**{resume_node_title}**') + stored_form_content = self.card_form_content.get(card_id, '') + if stored_form_content: + notice_parts.append(stored_form_content) + completed_lines = _lark_completed_input_lines( + { + 'input_defs': self.card_form_input_defs.get(card_id, []), + 'inputs': self.card_form_inputs.get(card_id, {}), + } + ) + if completed_lines and not all(line in stored_form_content for line in completed_lines): + notice_parts.append('---\n' + '\n'.join(completed_lines)) + notice_parts.append(f'---\n✅ {action_title}') + selected_notice = '\n\n'.join(notice_parts) + else: + selected_notice = '' + + # ── convert message chain → text ───────────────────────────────────── + text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) + + text_message = '' + if text_elements: + parts = [] + for paragraph in text_elements: + para_text = ''.join(ele['text'] for ele in paragraph if ele['tag'] in ('text', 'md')) + if para_text: + parts.append(para_text) + text_message = '\n\n'.join(parts) + + tenant_key = ( + message_source.source_platform_object.header.tenant_key if message_source.source_platform_object else None + ) + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + + card_sequence = self._next_card_sequence(card_id, msg_seq) + + # ── RESUME: first chunk after button click ─────────────────────────── + if resume_from and card_id not in self.card_resume_transitioned: + # Transition the card from the form state into resume mode. + # Preserve the text that was shown before the pause, and seed the + # resume placeholder with the current resume content if we already + # have any on the first yielded chunk. + pre_pause_text = self.card_pre_pause_text.get(card_id) or self.card_streaming_text.get(card_id, '') + initial_resume_text = text_message or '\u200b' + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=pre_pause_text, + sequence=card_sequence, + form_data=None, + notice_text=selected_notice, + resume_placeholder_text=initial_resume_text, + ) + self.card_resume_transitioned.add(card_id) + self.card_pre_pause_text[card_id] = pre_pause_text + self.card_streaming_text[card_id] = text_message + if not is_final: + return + + # ── RESUME: subsequent chunks → full card update ───────────────────── + if resume_from and card_id in self.card_resume_transitioned: + cached = self.card_streaming_text.get(card_id, '') + if text_message != cached: + self.card_streaming_text[card_id] = text_message + pre_pause_text = self.card_pre_pause_text.get(card_id, '') + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=pre_pause_text, + sequence=card_sequence, + form_data=None, + notice_text=selected_notice, + resume_placeholder_text=text_message, + ) + if not is_final: + return + + # ── NORMAL streaming (non-resume): update streaming_txt in-place ────── + if _lark_should_update_stream_element( + resume_from=resume_from, + form_data=form_data, + msg_seq=msg_seq, + is_final=is_final, + ): + cached = self.card_streaming_text.get(card_id) + if text_message != cached: + self.card_streaming_text[card_id] = text_message + request: ContentCardElementRequest = ( + ContentCardElementRequest.builder() + .card_id(card_id) + .element_id('streaming_txt') + .request_body( + ContentCardElementRequestBody.builder().content(text_message).sequence(card_sequence).build() + ) + .build() + ) + response: ContentCardElementResponse = await self.api_client.cardkit.v1.card_element.acontent( + request, req_opt + ) + if not response.success(): + raise Exception( + f'client.cardkit.v1.card_element.acontent failed, code: {response.code}, ' + f'msg: {response.msg}, log_id: {response.get_log_id()}, ' + f'resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + # ── FINAL chunk: full card layout update ───────────────────────────── + if is_final: + final_seq = self._next_card_sequence(card_id, card_sequence + 1) + pre_pause = self.card_pre_pause_text.get(card_id, text_message) + resume_cached = self.card_streaming_text.get(card_id, '') + if form_data: + if open_new_card: + # The old card has already been laid out into resume mode + # by the resume-transition block above (notice + resume + # placeholder). Finalise it as a frozen step snapshot and + # spawn a brand-new card to host the next human-input + # prompt — each step stays visible as its own card in the + # chat history. + new_card_id = await self._open_new_form_card(message_id, message_source, form_data) + if new_card_id is None: + # Fallback: keep the existing in-place behaviour so the + # workflow remains continuable even if creating the + # new card failed. + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=pre_pause, + sequence=final_seq, + form_data=form_data, + resume_placeholder_text=resume_cached, + show_form_prompt=True, + ) + self.card_streaming_text.pop(card_id, None) + self.card_pre_pause_text.pop(card_id, None) + self.card_form_content.pop(card_id, None) + self.card_form_input_defs.pop(card_id, None) + self.card_form_inputs.pop(card_id, None) + else: + # The old card is now a frozen snapshot; let go of its + # streaming-side state but keep its source registrations + # intact (no _drop_card_state) so historical button + # callbacks aimed at it can still be matched if needed. + self.card_streaming_text.pop(card_id, None) + self.card_pre_pause_text.pop(card_id, None) + self.card_form_content.pop(card_id, None) + self.card_form_input_defs.pop(card_id, None) + self.card_form_inputs.pop(card_id, None) + self.card_resume_transitioned.discard(card_id) + else: + # Initial pause path: render prompt + buttons in place on + # the current card. + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=text_message, + sequence=final_seq, + form_data=form_data, + show_form_prompt=True, + ) + # Preserve the pre-pause text so the main content can be + # restored when the user clicks a button and the card + # transitions to resume mode. + self.card_pre_pause_text[card_id] = self.card_streaming_text.get(card_id, '') + self.card_streaming_text[card_id] = '' + # Store cleaned form state for the resume notice. + self.card_form_content[card_id] = _lark_visible_form_content(form_data) + self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data) + self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) + else: + # Normal finish: keep pre-pause + resume content visible, + # remove buttons/notice, drop the resume placeholder. + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=pre_pause, + sequence=final_seq, + form_data=None, + notice_text=selected_notice if resume_from else '', + resume_placeholder_text=resume_cached, + ) + self._drop_card_state(card_id) + self.card_id_dict.pop(message_id, None) + + # ── media (images / files) appended at the end ─────────────────────── + if is_final and media_items: + for media in media_items: + media_request: ReplyMessageRequest = ( + ReplyMessageRequest.builder() + .message_id(message_source.message_chain.message_id) + .request_body( + ReplyMessageRequestBody.builder() + .content(json.dumps(media['content'])) + .msg_type(media['msg_type']) + .reply_in_thread(False) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + media_response: ReplyMessageResponse = await self.api_client.im.v1.message.areply( + media_request, req_opt + ) + if not media_response.success(): + raise Exception( + f'client.im.v1.message.reply ({media["msg_type"]}) failed, code: {media_response.code}, msg: {media_response.msg}, log_id: {media_response.get_log_id()}' + ) + + async def _add_form_buttons_to_card( + self, + card_id: str, + message_source: platform_events.MessageEvent, + form_data: dict, + text_message: str = '', + sequence: int = 1, + ): + """Update the entire card to include form action buttons. + + Uses card.aupdate to replace the card JSON with a template that + includes the streaming text content plus interactive buttons. + """ + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=text_message, + sequence=sequence, + form_data=form_data, + ) + + async def _remove_form_buttons_from_card( + self, + card_id: str, + message_source: platform_events.MessageEvent, + text_message: str = '', + sequence: int = 1, + ): + """Replace the human-input card layout with the plain final layout.""" + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=text_message, + sequence=sequence, + form_data=None, + ) + + def _build_lark_form_field_elements(self, form_data: dict) -> tuple[list[dict], dict[str, str], list[str]]: + elements: list[dict] = [] + input_name_map: dict[str, str] = {} + file_help_lines: list[str] = [] + + for idx, field in enumerate(_lark_current_input_defs(form_data), start=1): + field_name = _dify_field_name(field) + if not field_name: + continue + field_type = _dify_field_type(field) + + if field_type == 'select': + options = _dify_select_options(field) + component_name = _lark_form_component_name('Select', field_name, idx) + input_name_map[component_name] = field_name + elements.append( + { + 'tag': 'select_static', + 'name': component_name, + 'label': {'tag': 'plain_text', 'content': field_name}, + 'placeholder': {'tag': 'plain_text', 'content': '请选择'}, + 'options': [ + { + 'text': {'tag': 'plain_text', 'content': option}, + 'value': option, + } + for option in options + ], + 'type': 'default', + 'width': 'fill', + 'required': False, + } + ) + elif field_type in {'file', 'file-list'}: + allowed_types = ', '.join(field.get('allowed_file_types') or []) + allowed = f' ({allowed_types})' if allowed_types else '' + if field_type == 'file-list': + limit = field.get('number_limits') + suffix = f', up to {limit}' if limit else '' + file_help_lines.append( + f'- {field_name}: upload file(s){allowed}{suffix} in chat or reply `{field_name}: `' + ) + else: + file_help_lines.append( + f'- {field_name}: upload a file{allowed} in chat or reply `{field_name}: `' + ) + else: + component_name = _lark_form_component_name('Input', field_name, idx) + input_name_map[component_name] = field_name + is_multiline = field_type in {'paragraph', 'long_text', 'multiline_text', 'textarea'} + input_element = { + 'tag': 'input', + 'name': component_name, + 'label': {'tag': 'plain_text', 'content': field_name}, + 'placeholder': {'tag': 'plain_text', 'content': '请输入'}, + 'default_value': _dify_default_value(field), + 'width': 'fill', + 'required': False, + } + if is_multiline: + input_element.update( + { + 'input_type': 'multiline_text', + 'rows': 3, + 'auto_resize': True, + 'max_rows': 6, + } + ) + elements.append(input_element) + + return elements, input_name_map, file_help_lines + + async def _update_card_layout( + self, + card_id: str, + message_source: platform_events.MessageEvent, + text_message: str = '', + sequence: int = 1, + form_data: dict | None = None, + notice_text: str = '', + resume_placeholder_text: str = '', + show_form_prompt: bool = True, + ): + """Update the entire card layout. + + • form_data → show interactive buttons (initial Dify pause) + • notice_text → replace buttons with a grey selection notice (resume transition) + • resume_placeholder_text → add a streaming_txt_resume markdown element + """ + form_data = form_data or {} + actions = form_data.get('actions', []) + form_token = form_data.get('form_token', '') + workflow_run_id = form_data.get('workflow_run_id', '') + pipeline_uuid = form_data.get('pipeline_uuid', '') + node_title = form_data.get('node_title', '') or 'Human Input Required' + form_content = form_data.get('form_content', '') + input_defs = _lark_form_input_defs(form_data) + + # When form_data is set, the visible content is rendered inside the + # interactive container, so the top streaming text should stay empty + # to avoid duplicate text above the action area. + # + # For resume notice state, keep the existing text visible in the card + # and only add the grey "selected" notice below it. + if form_data: + render_text_message = '' + else: + render_text_message = text_message + + # Determine session key from message source + if isinstance(message_source, platform_events.GroupMessage): + session_key = f'group_{message_source.group.id}' + else: + session_key = f'person_{message_source.sender.id}' + + # Build button elements matching the existing card template's thumbsup/down format + action_buttons = [] + form_field_elements, input_name_map, file_help_lines = self._build_lark_form_field_elements(form_data) + uses_form_container = bool(form_field_elements or input_name_map) + if form_data: + form_content = _lark_visible_form_content(form_data) + self.card_form_content[card_id] = form_content + self.card_form_input_defs[card_id] = input_defs + self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) + is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') + if is_field_step: + actions = ( + [{'_input_progress': True, 'id': '', 'title': 'Next', 'button_style': 'primary'}] + if uses_form_container + else [] + ) + for action in actions: + action_id = action.get('id', '') + action_title = action.get('title', action_id) + button_style = action.get('button_style', 'default') + + if button_style == 'primary': + lark_button_type = 'primary' + elif button_style == 'danger': + lark_button_type = 'danger' + else: + lark_button_type = 'default' + + button = { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': action_title}, + 'type': lark_button_type, + 'width': 'fill', + 'size': 'medium', + 'hover_tips': {'tag': 'plain_text', 'content': action_title}, + 'behaviors': [ + { + 'type': 'callback', + 'value': { + 'form_action': True, + 'form_token': form_token, + 'workflow_run_id': workflow_run_id, + 'pipeline_uuid': pipeline_uuid, + 'action_id': action_id, + 'session_key': session_key, + 'card_id': card_id, + 'input_name_map': input_name_map, + '_input_progress': bool(action.get('_input_progress')), + }, + } + ], + 'margin': '0px 0px 0px 0px', + } + if uses_form_container: + button['name'] = _lark_form_component_name('Button', action_id or action_title, len(action_buttons) + 1) + button['form_action_type'] = 'submit' + action_buttons.append(button) + + interactive_elements = [] + if form_data: + if show_form_prompt: + interactive_elements = [ + { + 'tag': 'markdown', + 'content': f'**[Human Input Required] {node_title}**', + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 4px 0px', + } + ] + if form_content: + interactive_elements.append( + { + 'tag': 'markdown', + 'content': form_content, + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 8px 0px', + } + ) + completed_lines = ( + [] + if form_data.get('_action_select_only') + else _lark_completed_input_lines( + { + 'input_defs': input_defs, + 'inputs': form_data.get('inputs') or {}, + } + ) + ) + if completed_lines: + interactive_elements.append( + { + 'tag': 'markdown', + 'content': '---\n' + '\n'.join(completed_lines), + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 8px 0px', + } + ) + if file_help_lines: + interactive_elements.append( + { + 'tag': 'markdown', + 'content': '\n'.join(file_help_lines), + 'text_align': 'left', + 'text_size': 'normal', + 'text_color': 'grey', + 'margin': '0px 0px 8px 0px', + } + ) + if action_buttons: + interactive_elements.append( + { + 'tag': 'column_set', + 'horizontal_spacing': '8px', + 'horizontal_align': 'left', + 'margin': '0px 0px 0px 0px', + 'columns': [ + { + 'tag': 'column', + 'width': 'weighted', + 'elements': [btn], + 'padding': '0px 0px 0px 0px', + } + for btn in action_buttons + ], + } + ) + + # Build the full card JSON with buttons, same structure as create_card_id + # ── mid_section: either form buttons, resume notice, or empty ── + mid_section_elements = [] + if form_data: + if uses_form_container: + form_elements = interactive_elements[:-1] if action_buttons else interactive_elements[:] + form_elements.extend(form_field_elements) + if action_buttons: + form_elements.append(interactive_elements[-1]) + mid_section_elements = [ + { + 'tag': 'form', + 'name': _lark_form_component_name('Form', form_token or workflow_run_id or card_id, 1), + 'direction': 'vertical', + 'vertical_spacing': '12px', + 'margin': '12px 0px 8px 0px', + 'padding': '12px 12px 12px 12px', + 'elements': form_elements, + }, + {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, + ] + else: + mid_section_elements = [ + { + 'tag': 'interactive_container', + 'margin': '12px 0px 8px 0px', + 'padding': '12px 12px 12px 12px', + 'has_border': True, + 'elements': interactive_elements, + }, + {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, + ] + elif notice_text: + mid_section_elements = [ + { + 'tag': 'markdown', + 'content': notice_text, + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '8px 0px 4px 0px', + 'text_color': 'grey', + }, + {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, + ] + + # ── resume placeholder element (empty, filled via acontent on each chunk) ── + resume_elements = [] + if resume_placeholder_text: + resume_elements = [ + { + 'tag': 'markdown', + 'content': resume_placeholder_text, + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 0px 0px', + 'element_id': 'streaming_txt_resume', + }, + ] + + card_data = { + 'schema': '2.0', + 'config': { + 'update_multi': True, + 'streaming_mode': False, + }, + 'body': { + 'direction': 'vertical', + 'padding': '12px 12px 12px 12px', + 'elements': [ + { + 'tag': 'div', + 'text': { + 'tag': 'plain_text', + 'content': 'LangBot', + 'text_size': 'normal', + 'text_align': 'left', + 'text_color': 'default', + }, + 'icon': { + 'tag': 'custom_icon', + 'img_key': 'img_v3_02p3_05c65d5d-9bad-440a-a2fb-c89571bfd5bg', + }, + }, + { + 'tag': 'markdown', + 'content': render_text_message, + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 0px 0px', + 'element_id': 'streaming_txt', + }, + *mid_section_elements, + *resume_elements, + { + 'tag': 'column_set', + 'horizontal_spacing': '12px', + 'horizontal_align': 'right', + 'columns': [ + { + 'tag': 'column', + 'width': 'weighted', + 'elements': [ + { + 'tag': 'markdown', + 'content': '以上内容由 AI 生成,仅供参考。更多详细、准确信息可点击引用链接查看', + 'text_align': 'left', + 'text_size': 'notation', + 'margin': '4px 0px 0px 0px', + 'icon': { + 'tag': 'standard_icon', + 'token': 'robot_outlined', + 'color': 'grey', + }, + } + ], + 'padding': '0px 0px 0px 0px', + 'direction': 'vertical', + 'horizontal_spacing': '8px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + 'weight': 1, + }, + *( + [] + if form_data + else [ + { + 'tag': 'column', + 'width': '20px', + 'elements': [ + { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': ''}, + 'type': 'text', + 'width': 'fill', + 'size': 'medium', + 'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'}, + 'hover_tips': {'tag': 'plain_text', 'content': '有帮助'}, + 'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}], + 'margin': '0px 0px 0px 0px', + } + ], + 'padding': '0px 0px 0px 0px', + 'direction': 'vertical', + 'horizontal_spacing': '8px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + }, + { + 'tag': 'column', + 'width': '30px', + 'elements': [ + { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': ''}, + 'type': 'text', + 'width': 'default', + 'size': 'medium', + 'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'}, + 'hover_tips': {'tag': 'plain_text', 'content': '无帮助'}, + 'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}], + 'margin': '0px 0px 0px 0px', + } + ], + 'padding': '0px 0px 0px 0px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + }, + ] + ), + ], + 'margin': '0px 0px 4px 0px', + }, + ], + }, + } + + try: + tenant_key = ( + message_source.source_platform_object.header.tenant_key + if message_source.source_platform_object + else None + ) + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + + request: UpdateCardRequest = ( + UpdateCardRequest.builder() + .card_id(card_id) + .request_body( + UpdateCardRequestBody.builder() + .sequence(sequence) + .uuid(str(uuid.uuid4())) + .card(Card.builder().type('card_json').data(json.dumps(card_data)).build()) + .build() + ) + .build() + ) + response: UpdateCardResponse = await self.api_client.cardkit.v1.card.aupdate(request, req_opt) + if not response.success(): + await self.logger.error( + f'Failed to update lark card with form buttons: code={response.code}, msg={response.msg}, ' + f'log_id={response.get_log_id()}, resp={getattr(getattr(response, "raw", None), "content", None)}' + ) + except Exception: + await self.logger.error(f'Error updating lark card with form buttons: {traceback.format_exc()}') + + async def is_muted(self, group_id: int) -> bool: + return False + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners.pop(event_type) + + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + + def get_event_type(self, data): + schema = '1.0' + if 'schema' in data: + schema = data['schema'] + if '2.0' == schema: + return data['header']['event_type'] + elif 'event' in data: + return data['event']['type'] + else: + return data['type'] + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + """处理统一 webhook 请求。 + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) + request: Quart Request 对象 + Returns: + 响应数据 + """ + try: + data = await request.json + + if 'encrypt' in data: + data = self.cipher.decrypt_string(data['encrypt']) + data = json.loads(data) + type = self.get_event_type(data) + context = EventContext(data) + if 'url_verification' == type: + # todo 验证verification token + return {'challenge': data.get('challenge')} + elif 'app_ticket' == type: + self.app_ticket = context.event['app_ticket'] + elif 'im.message.receive_v1' == type: + try: + p2v1 = P2ImMessageReceiveV1() + p2v1.header = context.header + event = P2ImMessageReceiveV1Data() + event.message = EventMessage(context.event['message']) + event.sender = EventSender(context.event['sender']) + p2v1.event = event + p2v1.schema = context.schema + event = await self.event_converter.target2yiri(p2v1, self.api_client) + except Exception: + await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') + + if event.__class__ in self.listeners: + await self.listeners[event.__class__](event, self) + elif 'card.action.trigger' == type: + try: + event_data = data.get('event', {}) + operator = event_data.get('operator', {}) + action = event_data.get('action', {}) + context_data = event_data.get('context', {}) + + action_value_obj = _lark_mapping_from_value(action.get('value', {})) + action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else '' + + if isinstance(action_value_obj, dict) and action_value_obj.get('form_action'): + form_token = action_value_obj.get('form_token', '') + workflow_run_id = action_value_obj.get('workflow_run_id', '') + action_id = action_value_obj.get('action_id', '') + session_key = action_value_obj.get('session_key', '') + form_inputs = _lark_extract_action_form_inputs(action, action_value_obj) + + if session_key.startswith('group_') or session_key.startswith('g:'): + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('g:') + else session_key[len('group_') :] + ) + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('p:') + else session_key[len('person_') :] + ) + + form_action_data = { + 'form_token': form_token, + 'workflow_run_id': workflow_run_id, + 'action_id': action_id, + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': form_inputs, + } + if action_value_obj.get('_input_progress'): + form_action_data['_input_progress'] = True + + open_message_id = context_data.get('open_message_id') + card_id = self.reply_message_card_ids.get(str(open_message_id)) if open_message_id else None + if not card_id: + card_id = str(action_value_obj.get('card_id') or '') + if card_id and form_inputs: + cached_inputs = dict(self.card_form_inputs.get(card_id) or {}) + cached_inputs.update(form_inputs) + self.card_form_inputs[card_id] = cached_inputs + if self.ap is not None: + self.ap.logger.info( + f'Lark form action inputs cached: card_id={card_id} ' + f'open_message_id={open_message_id} keys={list(form_inputs.keys())}' + ) + + source_time = datetime.datetime.now() + message_chain = platform_message.MessageChain( + [platform_message.Plain(text=f'[Form Action: {action_id or "confirm"}]')] + ) + if open_message_id: + message_chain.insert( + 0, + platform_message.Source( + id=open_message_id, + time=source_time, + ), + ) + + user_id = operator.get('open_id') or operator.get('user_id') or str(launcher_id) + event_time = source_time.timestamp() + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + ), + message_chain=message_chain, + time=event_time, + source_platform_object=data, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=event_time, + source_platform_object=data, + ) + + bot_uuid = '' + pipeline_uuid = action_value_obj.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=user_id, + message_event=synthetic_event, + message_chain=message_chain, + adapter=self, + pipeline_uuid=pipeline_uuid, + variables={ + '_dify_form_action': form_action_data, + '_routed_by_rule': True, + }, + ) + + return {'toast': {'type': 'success', 'content': '操作成功'}} + + if action_value == '有帮助': + feedback_type = 1 + elif action_value == '无帮助': + feedback_type = 2 + else: + return {'toast': {'type': 'success', 'content': '操作成功'}} + + user_id = operator.get('open_id') or operator.get('user_id') + open_chat_id = context_data.get('open_chat_id') + open_message_id = context_data.get('open_message_id') + + if open_chat_id: + session_id = f'group_{open_chat_id}' + elif user_id: + session_id = f'person_{user_id}' + else: + session_id = None + + # Resolve monitoring message ID from reply message mapping + monitoring_msg_id = None + if open_message_id and open_message_id in self.reply_to_monitoring_msg: + monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0] + + feedback_event = platform_events.FeedbackEvent( + feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())), + feedback_type=feedback_type, + feedback_content=action_value, + user_id=user_id, + session_id=session_id, + message_id=open_message_id, + stream_id=monitoring_msg_id, + source_platform_object=data, + ) + + if platform_events.FeedbackEvent in self.listeners: + await self.listeners[platform_events.FeedbackEvent](feedback_event, self) + + return {'toast': {'type': 'success', 'content': '感谢您的反馈'}} + except Exception: + await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}') + return {'toast': {'type': 'error', 'content': '反馈处理失败'}} + + elif 'im.chat.member.bot.added_v1' == type: + try: + bot_added_welcome_msg = self.config.get('bot_added_welcome', '') + if bot_added_welcome_msg: + final_content = { + 'zh_Hans': { + 'title': '', + 'content': [[{'tag': 'md', 'text': bot_added_welcome_msg}]], + }, + } + chat_id = context.event['chat_id'] + request: CreateMessageRequest = ( + CreateMessageRequest.builder() + .receive_id_type('chat_id') + .request_body( + CreateMessageRequestBody.builder() + .receive_id(chat_id) + .content(json.dumps(final_content)) + .msg_type('post') + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + tenant_key = context.header.tenant_key if context.header else None + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt) + + if not response.success(): + raise Exception( + f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + except Exception as e: + print(f'im.chat.member.bot.added_v1: {e}') + await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') + + return {'code': 200, 'message': 'ok'} + except Exception as e: + print(f'Error in lark callback: {e}') + await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') + return {'code': 500, 'message': 'error'} + + async def run_async(self): + enable_webhook = self.config['enable-webhook'] + + if not enable_webhook: + try: + await self.bot._connect() + except lark_oapi.ws.exception.ClientException as e: + raise e + except Exception as e: + await self.bot._disconnect() + if self.bot._auto_reconnect: + await self.bot._reconnect() + else: + raise e + else: + # 统一 webhook 模式下,不启动独立的 Quart 应用 + # 保持运行但不启动独立端口 + + async def keep_alive(): + while True: + await asyncio.sleep(1) + + await keep_alive() + + async def kill(self) -> bool: + # 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接 + # 断开时lark.ws.Client的_receive_message_loop会打印error日志: receive message loop exit。然后进行重连, + # 所以要设置_auto_reconnect=False,让其不重连。 + self.bot._auto_reconnect = False + await self.bot._disconnect() + return False diff --git a/src/langbot/pkg/platform/sources/lark.svg b/src/langbot/pkg/platform/sources/lark.svg new file mode 100644 index 0000000..bf3c202 --- /dev/null +++ b/src/langbot/pkg/platform/sources/lark.svg @@ -0,0 +1 @@ + diff --git a/src/langbot/pkg/platform/sources/lark.yaml b/src/langbot/pkg/platform/sources/lark.yaml new file mode 100644 index 0000000..94509c4 --- /dev/null +++ b/src/langbot/pkg/platform/sources/lark.yaml @@ -0,0 +1,231 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: lark + label: + en_US: Lark + zh_Hans: 飞书 + zh_Hant: 飛書 + ja_JP: Lark + description: + en_US: Lark Adapter, supports both long connection and Webhook modes. Please refer to the documentation for usage details. + zh_Hans: 飞书适配器,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式 + zh_Hant: 飛書適配器,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式 + ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。 + icon: lark.svg +spec: + categories: + - popular + - china + - global + help_links: + zh: https://link.langbot.app/zh/platforms/lark + en: https://link.langbot.app/en/platforms/lark + ja: https://link.langbot.app/ja/platforms/lark + config: + - name: domain + label: + en_US: Platform Domain + zh_Hans: 平台域名 + zh_Hant: 平台域名 + ja_JP: プラットフォームドメイン + description: + en_US: Select the open platform domain. Use Feishu for Chinese mainland, Lark for international + zh_Hans: 选择开放平台域名,国内使用飞书,海外使用 Lark + zh_Hant: 選擇開放平台域名,國內使用飛書,海外使用 Lark + ja_JP: オープンプラットフォームのドメインを選択。中国国内は飛書、海外は Lark を使用 + type: select + options: + - name: https://open.feishu.cn + label: + en_US: Feishu (open.feishu.cn) + zh_Hans: 飞书 (open.feishu.cn) + zh_Hant: 飛書 (open.feishu.cn) + ja_JP: 飛書 (open.feishu.cn) + - name: https://open.larksuite.com + label: + en_US: Lark (open.larksuite.com) + zh_Hans: Lark (open.larksuite.com) + zh_Hant: Lark (open.larksuite.com) + ja_JP: Lark (open.larksuite.com) + - name: custom + label: + en_US: Custom + zh_Hans: 自定义 + zh_Hant: 自定義 + ja_JP: カスタム + required: false + default: https://open.feishu.cn + - name: custom_domain + label: + en_US: Custom Domain + zh_Hans: 自定义域名 + zh_Hant: 自定義域名 + ja_JP: カスタムドメイン + description: + en_US: "Enter the full domain URL, e.g. https://open.example.com" + zh_Hans: "输入完整的域名 URL,例如 https://open.example.com" + zh_Hant: "輸入完整的域名 URL,例如 https://open.example.com" + ja_JP: "完全なドメイン URL を入力(例: https://open.example.com)" + type: string + required: false + default: "" + show_if: + field: domain + operator: eq + value: custom + - name: one-click-create + label: + en_US: One-Click Create App + zh_Hans: 一键创建应用 + zh_Hant: 一鍵建立應用 + ja_JP: ワンクリックでアプリ作成 + description: + en_US: Scan QR code to automatically create a Feishu app and fill in credentials + zh_Hans: 扫码自动创建飞书应用并填写凭据 + zh_Hant: 掃碼自動建立飛書應用並填寫憑證 + ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力 + type: qr-code-login + login_platform: feishu + required: false + - name: app_id + label: + en_US: App ID + zh_Hans: 应用ID + zh_Hant: 應用ID + ja_JP: アプリ ID + type: string + required: true + default: "" + - name: app_secret + label: + en_US: App Secret + zh_Hans: 应用密钥 + zh_Hant: 應用密鑰 + ja_JP: アプリシークレット + type: string + required: true + default: "" + - name: bot_name + label: + en_US: Bot Name + zh_Hans: 机器人名称 + zh_Hant: 機器人名稱 + ja_JP: ボット名 + description: + en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group + zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息 + zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常接收訊息 + ja_JP: Lark のボット名と一致する必要があります。一致しない場合、グループ内でメッセージを受信できません + type: string + required: true + default: "" + - name: enable-webhook + label: + en_US: Enable Webhook Mode + zh_Hans: 启用Webhook模式 + zh_Hant: 啟用 Webhook 模式 + ja_JP: Webhook モードを有効化 + description: + en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode + zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式 + zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式 + ja_JP: 有効にすると、ボットは Webhook モードでメッセージを受信します。無効の場合は WS 長期接続モードを使用します + type: boolean + required: true + default: false + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + ja_JP: Webhook コールバック URL + description: + en_US: Copy this URL and paste it into your Lark app's webhook configuration + zh_Hans: 复制此地址并粘贴到飞书应用的 Webhook 配置中 + zh_Hant: 複製此地址並貼到飛書應用的 Webhook 設定中 + ja_JP: この URL をコピーして Lark アプリの Webhook 設定に貼り付けてください + type: webhook-url + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true + - name: encrypt-key + label: + en_US: Encrypt Key + zh_Hans: 加密密钥 + zh_Hant: 加密密鑰 + ja_JP: 暗号化キー + description: + en_US: Only valid when webhook mode is enabled, please fill in the encrypt key + zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥 + zh_Hant: 僅在啟用 Webhook 模式時有效,請填寫加密密鑰 + ja_JP: Webhook モードが有効な場合にのみ有効です。暗号化キーを入力してください + type: string + required: true + default: "" + show_if: + field: enable-webhook + operator: eq + value: true + - name: enable-stream-reply + label: + en_US: Enable Stream Reply Mode + zh_Hans: 启用飞书流式回复模式 + zh_Hant: 啟用飛書串流回覆模式 + ja_JP: ストリーミング返信モードを有効化 + description: + en_US: If enabled, the bot will use the stream of lark reply mode + zh_Hans: 如果启用,将使用飞书流式方式来回复内容 + zh_Hant: 如果啟用,將使用飛書串流方式來回覆內容 + ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します + type: boolean + required: true + default: false + - name: app_type + label: + en_US: App Type + zh_Hans: 应用类型 + zh_Hant: 應用類型 + ja_JP: アプリタイプ + description: + en_US: "Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview" + zh_Hans: "默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview" + zh_Hant: "預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview" + ja_JP: "デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください" + type: select + options: + - name: self + label: + en_US: Self-built Application + zh_Hans: 自建应用 + zh_Hant: 自建應用 + ja_JP: カスタムアプリ + - name: isv + label: + en_US: Store Application + zh_Hans: 商店应用 + zh_Hant: 商店應用 + ja_JP: ストアアプリ + required: false + default: self + - name: bot_added_welcome + label: + en_US: Bot Welcome Message + zh_Hans: 机器人进群欢迎语 + zh_Hant: 機器人進群歡迎語 + ja_JP: ボット参加時のウェルカムメッセージ + description: + en_US: Welcome message when the bot is added to a group, supports Markdown format + zh_Hans: 机器人进群欢迎语,支持 Markdown 格式 + zh_Hant: 機器人進群歡迎語,支援 Markdown 格式 + ja_JP: ボットがグループに追加された際のウェルカムメッセージ。Markdown 形式に対応しています + type: text + required: false + default: "" +execution: + python: + path: ./lark.py + attr: LarkAdapter \ No newline at end of file diff --git a/src/langbot/pkg/platform/sources/legacy/gewechat.png b/src/langbot/pkg/platform/sources/legacy/gewechat.png new file mode 100644 index 0000000..32b2fa3 Binary files /dev/null and b/src/langbot/pkg/platform/sources/legacy/gewechat.png differ diff --git a/src/langbot/pkg/platform/sources/legacy/gewechat.py b/src/langbot/pkg/platform/sources/legacy/gewechat.py new file mode 100644 index 0000000..68e1bde --- /dev/null +++ b/src/langbot/pkg/platform/sources/legacy/gewechat.py @@ -0,0 +1,688 @@ +import gewechat_client + +import typing +import asyncio +import traceback +import time +import re +import copy +import threading + +import quart +from langbot.pkg.utils import httpclient + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from ....core import app +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +from ....utils import image +import xml.etree.ElementTree as ET +from typing import Optional, Tuple +from functools import partial +from ...logger import EventLogger + + +class GewechatMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + def __init__(self, config: dict): + self.config = config + + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]: + content_list = [] + for component in message_chain: + if isinstance(component, platform_message.At): + content_list.append({'type': 'at', 'target': component.target}) + elif isinstance(component, platform_message.Plain): + content_list.append({'type': 'text', 'content': component.text}) + elif isinstance(component, platform_message.Image): + if not component.url: + pass + content_list.append({'type': 'image', 'image': component.url}) + + elif isinstance(component, platform_message.Voice): + content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain)) + content_list.append({'type': 'image', 'image': component.url}) + elif isinstance(component, platform_message.WeChatMiniPrograms): + content_list.append( + { + 'type': 'WeChatMiniPrograms', + 'mini_app_id': component.mini_app_id, + 'display_name': component.display_name, + 'page_path': component.page_path, + 'cover_img_url': component.image_url, + 'title': component.title, + 'user_name': component.user_name, + } + ) + elif isinstance(component, platform_message.WeChatForwardMiniPrograms): + content_list.append( + { + 'type': 'WeChatForwardMiniPrograms', + 'xml_data': component.xml_data, + 'image_url': component.image_url, + } + ) + elif isinstance(component, platform_message.WeChatEmoji): + content_list.append( + { + 'type': 'WeChatEmoji', + 'emoji_md5': component.emoji_md5, + 'emoji_size': component.emoji_size, + } + ) + elif isinstance(component, platform_message.WeChatLink): + content_list.append( + { + 'type': 'WeChatLink', + 'link_title': component.link_title, + 'link_desc': component.link_desc, + 'link_thumb_url': component.link_thumb_url, + 'link_url': component.link_url, + } + ) + elif isinstance(component, platform_message.WeChatForwardLink): + content_list.append({'type': 'WeChatForwardLink', 'xml_data': component.xml_data}) + elif isinstance(component, platform_message.Voice): + content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) + elif isinstance(component, platform_message.WeChatForwardImage): + content_list.append({'type': 'WeChatForwardImage', 'xml_data': component.xml_data}) + elif isinstance(component, platform_message.WeChatForwardFile): + content_list.append({'type': 'WeChatForwardFile', 'xml_data': component.xml_data}) + elif isinstance(component, platform_message.WeChatAppMsg): + content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg}) + # 引用消息转发 + elif isinstance(component, platform_message.WeChatForwardQuote): + content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg}) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain)) + + return content_list + + async def target2yiri(self, message: dict, bot_account_id: str) -> platform_message.MessageChain: + """外部消息转平台消息""" + # 数据预处理 + message_list = [] + ats_bot = False # 是否被@ + content = message['Data']['Content']['string'] + content_no_preifx = content # 群消息则去掉前缀 + is_group_message = self._is_group_message(message) + if is_group_message: + ats_bot = self._ats_bot(message, bot_account_id) + if '@所有人' in content: + message_list.append(platform_message.AtAll()) + elif ats_bot: + message_list.append(platform_message.At(target=bot_account_id)) + content_no_preifx, _ = self._extract_content_and_sender(content) + + msg_type = message['Data']['MsgType'] + + # 映射消息类型到处理器方法 + handler_map = { + 1: self._handler_text, + 3: self._handler_image, + 34: self._handler_voice, + 49: self._handler_compound, # 复合类型 + } + + # 分派处理 + handler = handler_map.get(msg_type, self._handler_default) + handler_result = await handler( + message=message, # 原始的message + content_no_preifx=content_no_preifx, # 处理后的content + ) + + if handler_result and len(handler_result) > 0: + message_list.extend(handler_result) + + return platform_message.MessageChain(message_list) + + async def _handler_text(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: + """处理文本消息 (msg_type=1)""" + if message and self._is_group_message(message): + pattern = r'@\S{1,20}' + content_no_preifx = re.sub(pattern, '', content_no_preifx) + + return platform_message.MessageChain([platform_message.Plain(content_no_preifx)]) + + async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: + """处理图像消息 (msg_type=3)""" + try: + image_xml = content_no_preifx + if not image_xml: + return platform_message.MessageChain([platform_message.Unknown('[图片内容为空]')]) + + base64_str, image_format = await image.get_gewechat_image_base64( + gewechat_url=self.config['gewechat_url'], + gewechat_file_url=self.config['gewechat_file_url'], + app_id=self.config['app_id'], + xml_content=image_xml, + token=self.config['token'], + image_type=2, + ) + + elements = [ + platform_message.Image(base64=f'data:image/{image_format};base64,{base64_str}'), + platform_message.WeChatForwardImage(xml_data=image_xml), # 微信消息转发 + ] + return platform_message.MessageChain(elements) + except Exception as e: + print(f'处理图片失败: {str(e)}') + return platform_message.MessageChain([platform_message.Unknown('[图片处理失败]')]) + + async def _handler_voice(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: + """处理语音消息 (msg_type=34)""" + message_List = [] + try: + # 从消息中提取语音数据(需根据实际数据结构调整字段名) + audio_base64 = message['Data']['ImgBuf']['buffer'] + + # 验证语音数据有效性 + if not audio_base64: + message_List.append(platform_message.Unknown(text='[语音内容为空]')) + return platform_message.MessageChain(message_List) + + # 转换为平台支持的语音格式(如 Silk 格式) + voice_element = platform_message.Voice(base64=f'data:audio/silk;base64,{audio_base64}') + message_List.append(voice_element) + + except KeyError as e: + print(f'语音数据字段缺失: {str(e)}') + message_List.append(platform_message.Unknown(text='[语音数据解析失败]')) + except Exception as e: + print(f'处理语音消息异常: {str(e)}') + message_List.append(platform_message.Unknown(text='[语音处理失败]')) + + return platform_message.MessageChain(message_List) + + async def _handler_compound(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: + """处理复合消息 (msg_type=49),根据子类型分派""" + try: + xml_data = ET.fromstring(content_no_preifx) + appmsg_data = xml_data.find('.//appmsg') + if appmsg_data: + data_type = appmsg_data.findtext('.//type', '') + + # 二次分派处理器 + sub_handler_map = { + '57': self._handler_compound_quote, + '5': self._handler_compound_link, + '6': self._handler_compound_file, + '33': self._handler_compound_mini_program, + '36': self._handler_compound_mini_program, + '2000': partial(self._handler_compound_unsupported, text='[转账消息]'), + '2001': partial(self._handler_compound_unsupported, text='[红包消息]'), + '51': partial(self._handler_compound_unsupported, text='[视频号消息]'), + } + + handler = sub_handler_map.get(data_type, self._handler_compound_unsupported) + return await handler( + message=message, # 原始msg + xml_data=xml_data, # xml数据 + ) + else: + return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)]) + except Exception as e: + print(f'解析复合消息失败: {str(e)}') + return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)]) + + async def _handler_compound_quote( + self, message: Optional[dict], xml_data: ET.Element + ) -> platform_message.MessageChain: + """处理引用消息 (data_type=57)""" + message_list = [] + # print("_handler_compound_quote", ET.tostring(xml_data, encoding='unicode')) + appmsg_data = xml_data.find('.//appmsg') + quote_data = '' # 引用原文 + user_data = '' # 用户消息 + sender_id = xml_data.findtext('.//fromusername') # 发送方:单聊用户/群member + if appmsg_data: + user_data = appmsg_data.findtext('.//title') or '' + quote_data = appmsg_data.find('.//refermsg').findtext('.//content') + message_list.append( + platform_message.WeChatForwardQuote(app_msg=ET.tostring(appmsg_data, encoding='unicode')) + ) + # quote_data原始的消息 + if quote_data: + quote_data_message_list = platform_message.MessageChain() + # 文本消息 + try: + if '' not in quote_data: + quote_data_message_list.append(platform_message.Plain(quote_data)) + else: + # 引用消息展开 + quote_data_xml = ET.fromstring(quote_data) + if quote_data_xml.find('img'): + quote_data_message_list.extend(await self._handler_image(None, quote_data)) + elif quote_data_xml.find('voicemsg'): + quote_data_message_list.extend(await self._handler_voice(None, quote_data)) + elif quote_data_xml.find('videomsg'): + quote_data_message_list.extend(await self._handler_default(None, quote_data)) # 先不处理 + else: + # appmsg + quote_data_message_list.extend(await self._handler_compound(None, quote_data)) + except Exception as e: + print(f'处理引用消息异常 expcetion:{e}') + quote_data_message_list.append(platform_message.Plain(quote_data)) + message_list.append( + platform_message.Quote( + sender_id=sender_id, + origin=quote_data_message_list, + ) + ) + if len(user_data) > 0: + pattern = r'@\S{1,20}' + user_data = re.sub(pattern, '', user_data) + message_list.append(platform_message.Plain(user_data)) + + # for comp in message_list: + # if isinstance(comp, platform_message.Quote): + # print(f"quote_message_chain len={len(message_list)}") + # print(f"quote_message_chain send_id={comp.sender_id}" ) + # for quote_item in comp.origin: + # print(f"--quote_message_component [msg_type={quote_item.type}][message={quote_item}]" ) + # else: + # print(f"quote_message_chain plain [msg_type={comp.type}][message={comp.text}]") + return platform_message.MessageChain(message_list) + + async def _handler_compound_file(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain: + """处理文件消息 (data_type=6)""" + xml_data_str = ET.tostring(xml_data, encoding='unicode') + return platform_message.MessageChain([platform_message.WeChatForwardFile(xml_data=xml_data_str)]) + + async def _handler_compound_link(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain: + """处理链接消息(如公众号文章、外部网页)""" + message_list = [] + try: + # 解析 XML 中的链接参数 + appmsg = xml_data.find('.//appmsg') + if appmsg is None: + return platform_message.MessageChain() + message_list.append( + platform_message.WeChatLink( + link_title=appmsg.findtext('title', ''), + link_desc=appmsg.findtext('des', ''), + link_url=appmsg.findtext('url', ''), + link_thumb_url=appmsg.findtext('thumburl', ''), # 这个字段拿不到 + ) + ) + # 转发消息 + xml_data_str = ET.tostring(xml_data, encoding='unicode') + # print(xml_data_str) + message_list.append(platform_message.WeChatForwardLink(xml_data=xml_data_str)) + except Exception as e: + print(f'解析链接消息失败: {str(e)}') + return platform_message.MessageChain(message_list) + + async def _handler_compound_mini_program( + self, message: dict, xml_data: ET.Element + ) -> platform_message.MessageChain: + """处理小程序消息(如小程序卡片、服务通知)""" + xml_data_str = ET.tostring(xml_data, encoding='unicode') + return platform_message.MessageChain([platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)]) + + async def _handler_default(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: + """处理未知消息类型""" + if message: + msg_type = message['Data']['MsgType'] + else: + msg_type = '' + return platform_message.MessageChain([platform_message.Unknown(text=f'[未知消息类型 msg_type:{msg_type}]')]) + + def _handler_compound_unsupported( + self, message: dict, xml_data: str, text: Optional[str] = None + ) -> platform_message.MessageChain: + """处理未支持复合消息类型(msg_type=49)子类型""" + if not text: + text = f'[xml_data={xml_data}]' + content_list = [] + content_list.append(platform_message.Unknown(text=f'[处理未支持复合消息类型[msg_type=49]|{text}')) + + return platform_message.MessageChain(content_list) + + # 返回是否被艾特 + def _ats_bot(self, message: dict, bot_account_id: str) -> bool: + ats_bot = False + try: + to_user_name = message['Wxid'] # 接收方: 所属微信的wxid + raw_content = message['Data']['Content']['string'] # 原始消息内容 + content_no_prefix, _ = self._extract_content_and_sender(raw_content) + # 直接艾特机器人(这个有bug,当被引用的消息里面有@bot,会套娃 + # ats_bot = ats_bot or (f"@{bot_account_id}" in content_no_prefix) + # 文本类@bot + push_content = message.get('Data', {}).get('PushContent', '') + ats_bot = ats_bot or ('在群聊中@了你' in push_content) + # 引用别人时@bot + msg_source = message.get('Data', {}).get('MsgSource', '') or '' + if len(msg_source) > 0: + msg_source_data = ET.fromstring(msg_source) + at_user_list = msg_source_data.findtext('atuserlist') or '' + ats_bot = ats_bot or (to_user_name in at_user_list) + # 引用bot + if message.get('Data', {}).get('MsgType', 0) == 49: + xml_data = ET.fromstring(content_no_prefix) + appmsg_data = xml_data.find('.//appmsg') + tousername = message['Wxid'] + if appmsg_data: # 接收方: 所属微信的wxid + quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr') # 引用消息的原发送者 + ats_bot = ats_bot or (quote_id == tousername) + except Exception as e: + print(f'Error in gewechat _ats_bot: {e}') + finally: + return ats_bot + + # 提取一下content前面的sender_id, 和去掉前缀的内容 + def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]: + try: + # 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉 + # add: 有些用户的wxid不是上述格式。换成user_name: + regex = re.compile(r'^[a-zA-Z0-9_\-]{5,20}:') + line_split = raw_content.split('\n') + if len(line_split) > 0 and regex.match(line_split[0]): + raw_content = '\n'.join(line_split[1:]) + sender_id = line_split[0].strip(':') + return raw_content, sender_id + except Exception as e: + print(f'_extract_content_and_sender got except: {e}') + finally: + return raw_content, None + + # 是否是群消息 + def _is_group_message(self, message: dict) -> bool: + from_user_name = message['Data']['FromUserName']['string'] + return from_user_name.endswith('@chatroom') + + +class GewechatEventConverter(abstract_platform_adapter.AbstractEventConverter): + def __init__(self, config: dict): + self.config = config + self.message_converter = GewechatMessageConverter(config) + + @staticmethod + async def yiri2target(event: platform_events.MessageEvent) -> dict: + pass + + async def target2yiri(self, event: dict, bot_account_id: str) -> platform_events.MessageEvent: + # print(event) + # 排除自己发消息回调回答问题 + if event['Wxid'] == event['Data']['FromUserName']['string']: + return None + # 排除公众号以及微信团队消息 + if event['Data']['FromUserName']['string'].startswith('gh_') or event['Data']['FromUserName'][ + 'string' + ].startswith('weixin'): + return None + message_chain = await self.message_converter.target2yiri(copy.deepcopy(event), bot_account_id) + + if not message_chain: + return None + + if '@chatroom' in event['Data']['FromUserName']['string']: + # 找出开头的 wxid_ 字符串,以:结尾 + sender_wxid = event['Data']['Content']['string'].split(':')[0] + + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=sender_wxid, + member_name=event['Data']['FromUserName']['string'], + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=event['Data']['FromUserName']['string'], + name=event['Data']['FromUserName']['string'], + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=event['Data']['CreateTime'], + source_platform_object=event, + ) + else: + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event['Data']['FromUserName']['string'], + nickname=event['Data']['FromUserName']['string'], + remark='', + ), + message_chain=message_chain, + time=event['Data']['CreateTime'], + source_platform_object=event, + ) + + +class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + name: str = 'gewechat' # 定义适配器名称 + + bot: gewechat_client.GewechatClient + quart_app: quart.Quart + + bot_account_id: str + + config: dict + + ap: app.Application + + message_converter: GewechatMessageConverter + event_converter: GewechatEventConverter + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): + self.config = config + self.ap = ap + self.logger = logger + self.quart_app = quart.Quart(__name__) + + self.message_converter = GewechatMessageConverter(config) + self.event_converter = GewechatEventConverter(config) + + @self.quart_app.route('/gewechat/callback', methods=['POST']) + async def gewechat_callback(): + data = await quart.request.json + # print(json.dumps(data, indent=4, ensure_ascii=False)) + await self.logger.debug(f'Gewechat callback event: {data}') + + if 'data' in data: + data['Data'] = data['data'] + if 'type_name' in data: + data['TypeName'] = data['type_name'] + # print(json.dumps(data, indent=4, ensure_ascii=False)) + + if 'testMsg' in data: + return 'ok' + elif 'TypeName' in data and data['TypeName'] == 'AddMsg': + try: + event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id) + except Exception: + await self.logger.error(f'Error in gewechat callback: {traceback.format_exc()}') + + if event.__class__ in self.listeners: + await self.listeners[event.__class__](event, self) + + return 'ok' + + async def _handle_message(self, message: platform_message.MessageChain, target_id: str): + """统一消息处理核心逻辑""" + content_list = await self.message_converter.yiri2target(message) + at_targets = [item['target'] for item in content_list if item['type'] == 'at'] + + # 处理@逻辑 + at_targets = at_targets or [] + member_info = [] + if at_targets: + member_info = self.bot.get_chatroom_member_detail(self.config['app_id'], target_id, at_targets[::-1])[ + 'data' + ] + + # 处理消息组件 + for msg in content_list: + # 文本消息处理@ + if msg['type'] == 'text' and at_targets: + for member in member_info: + msg['content'] = f'@{member["nickName"]} {msg["content"]}' + + # 统一消息派发 + handler_map = { + 'text': lambda msg: self.bot.post_text( + app_id=self.config['app_id'], + to_wxid=target_id, + content=msg['content'], + ats=','.join(at_targets), + ), + 'image': lambda msg: self.bot.post_image( + app_id=self.config['app_id'], + to_wxid=target_id, + img_url=msg['image'], + ), + 'WeChatForwardMiniPrograms': lambda msg: self.bot.forward_mini_app( + app_id=self.config['app_id'], + to_wxid=target_id, + xml=msg['xml_data'], + cover_img_url=msg.get('image_url'), + ), + 'WeChatEmoji': lambda msg: self.bot.post_emoji( + app_id=self.config['app_id'], + to_wxid=target_id, + emoji_md5=msg['emoji_md5'], + emoji_size=msg['emoji_size'], + ), + 'WeChatLink': lambda msg: self.bot.post_link( + app_id=self.config['app_id'], + to_wxid=target_id, + title=msg['link_title'], + desc=msg['link_desc'], + link_url=msg['link_url'], + thumb_url=msg['link_thumb_url'], + ), + 'WeChatMiniPrograms': lambda msg: self.bot.post_mini_app( + app_id=self.config['app_id'], + to_wxid=target_id, + mini_app_id=msg['mini_app_id'], + display_name=msg['display_name'], + page_path=msg['page_path'], + cover_img_url=msg['cover_img_url'], + title=msg['title'], + user_name=msg['user_name'], + ), + 'WeChatForwardLink': lambda msg: self.bot.forward_url( + app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] + ), + 'WeChatForwardImage': lambda msg: self.bot.forward_image( + app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] + ), + 'WeChatForwardFile': lambda msg: self.bot.forward_file( + app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] + ), + 'voice': lambda msg: self.bot.post_voice( + app_id=self.config['app_id'], + to_wxid=target_id, + voice_url=msg['url'], + voice_duration=msg['length'], + ), + 'WeChatAppMsg': lambda msg: self.bot.post_app_msg( + app_id=self.config['app_id'], + to_wxid=target_id, + appmsg=msg['app_msg'], + ), + 'at': lambda msg: None, + } + + if handler := handler_map.get(msg['type']): + handler(msg) + else: + await self.logger.warning(f'未处理的消息类型: {msg["type"]}') + continue + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + """主动发送消息""" + return await self._handle_message(message, target_id) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + """回复消息""" + if message_source.source_platform_object: + target_id = message_source.source_platform_object['Data']['FromUserName']['string'] + return await self._handle_message(message, target_id) + + async def is_muted(self, group_id: int) -> bool: + pass + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + pass + + async def run_async(self): + if not self.config['token']: + session = httpclient.get_session() + async with session.post( + f'{self.config["gewechat_url"]}/v2/api/tools/getTokenId', + json={'app_id': self.config['app_id']}, + ) as response: + if response.status != 200: + raise Exception(f'获取gewechat token失败: {await response.text()}') + self.config['token'] = (await response.json())['data'] + + self.bot = gewechat_client.GewechatClient(f'{self.config["gewechat_url"]}/v2/api', self.config['token']) + + def gewechat_login_process(): + app_id, error_msg = self.bot.login(self.config['app_id']) + if error_msg: + raise Exception(f'Gewechat 登录失败: {error_msg}') + + self.config['app_id'] = app_id + + print(f'Gewechat 登录成功,app_id: {app_id}') + + # 获取 nickname + profile = self.bot.get_profile(self.config['app_id']) + self.bot_account_id = profile['data']['nickName'] + + time.sleep(2) + + try: + # gewechat-server容器重启, token会变,但是还会登录成功 + # 换新token也会收不到回调,要重新登陆下。 + self.bot.set_callback(self.config['token'], self.config['callback_url']) + except Exception as e: + raise Exception(f'设置 Gewechat 回调失败, token失效: {e}') + + threading.Thread(target=gewechat_login_process).start() + + async def shutdown_trigger_placeholder(): + while True: + await asyncio.sleep(1) + + await self.quart_app.run_task( + host='0.0.0.0', + port=self.config['port'], + shutdown_trigger=shutdown_trigger_placeholder, + ) + + async def kill(self) -> bool: + pass diff --git a/src/langbot/pkg/platform/sources/legacy/gewechat.yaml b/src/langbot/pkg/platform/sources/legacy/gewechat.yaml new file mode 100644 index 0000000..ffd15dc --- /dev/null +++ b/src/langbot/pkg/platform/sources/legacy/gewechat.yaml @@ -0,0 +1,59 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: gewechat + label: + en_US: GeWeChat + zh_Hans: GeWeChat(个人微信) + description: + en_US: GeWeChat Adapter + zh_Hans: GeWeChat 适配器,请查看文档了解使用方式 + icon: gewechat.png +spec: + config: + - name: gewechat_url + label: + en_US: GeWeChat URL + zh_Hans: GeWeChat URL + type: string + required: true + default: "" + - name: gewechat_file_url + label: + en_US: GeWeChat file download URL + zh_Hans: GeWeChat 文件下载URL + type: string + required: true + default: "" + - name: port + label: + en_US: Port + zh_Hans: 端口 + type: integer + required: true + default: 2286 + - name: callback_url + label: + en_US: Callback URL + zh_Hans: 回调URL + type: string + required: true + default: "" + - name: app_id + label: + en_US: App ID + zh_Hans: 应用ID + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_Hans: 令牌 + type: string + required: true + default: "" +execution: + python: + path: ./gewechat.py + attr: GeWeChatAdapter diff --git a/src/langbot/pkg/platform/sources/legacy/nakuru.png b/src/langbot/pkg/platform/sources/legacy/nakuru.png new file mode 100644 index 0000000..0101afc Binary files /dev/null and b/src/langbot/pkg/platform/sources/legacy/nakuru.png differ diff --git a/src/langbot/pkg/platform/sources/legacy/nakuru.py b/src/langbot/pkg/platform/sources/legacy/nakuru.py new file mode 100644 index 0000000..1e34af0 --- /dev/null +++ b/src/langbot/pkg/platform/sources/legacy/nakuru.py @@ -0,0 +1,330 @@ +# 加了之后会导致:https://github.com/Lxns-Network/nakuru-project/issues/25 +# from __future__ import annotations + +import asyncio +import typing +import traceback + + +import nakuru +import nakuru.entities.components as nkc + +from ....pipeline.longtext.strategies import forward +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from ...logger import EventLogger + + +class NakuruProjectMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + """消息转换器""" + + @staticmethod + def yiri2target(message_chain: platform_message.MessageChain) -> list: + msg_list = [] + if type(message_chain) is platform_message.MessageChain: + msg_list = message_chain.__root__ + elif type(message_chain) is list: + msg_list = message_chain + elif type(message_chain) is str: + msg_list = [platform_message.Plain(message_chain)] + else: + raise Exception('Unknown message type: ' + str(message_chain) + str(type(message_chain))) + + nakuru_msg_list = [] + + # 遍历并转换 + for component in msg_list: + if type(component) is platform_message.Plain: + nakuru_msg_list.append(nkc.Plain(component.text, False)) + elif type(component) is platform_message.Image: + if component.url is not None: + nakuru_msg_list.append(nkc.Image.fromURL(component.url)) + elif component.base64 is not None: + nakuru_msg_list.append(nkc.Image.fromBase64(component.base64)) + elif component.path is not None: + nakuru_msg_list.append(nkc.Image.fromFileSystem(component.path)) + elif type(component) is platform_message.At: + nakuru_msg_list.append(nkc.At(qq=component.target)) + elif type(component) is platform_message.AtAll: + nakuru_msg_list.append(nkc.AtAll()) + elif type(component) is platform_message.Voice: + if component.url is not None: + nakuru_msg_list.append(nkc.Record.fromURL(component.url)) + elif component.path is not None: + nakuru_msg_list.append(nkc.Record.fromFileSystem(component.path)) + elif type(component) is forward.Forward: + # 转发消息 + yiri_forward_node_list = component.node_list + nakuru_forward_node_list = [] + + # 遍历并转换 + for yiri_forward_node in yiri_forward_node_list: + try: + content_list = NakuruProjectMessageConverter.yiri2target(yiri_forward_node.message_chain) + nakuru_forward_node = nkc.Node( + name=yiri_forward_node.sender_name, + uin=yiri_forward_node.sender_id, + time=int(yiri_forward_node.time.timestamp()) + if yiri_forward_node.time is not None + else None, + content=content_list, + ) + nakuru_forward_node_list.append(nakuru_forward_node) + except Exception: + import traceback + + traceback.print_exc() + + nakuru_msg_list.append(nakuru_forward_node_list) + else: + nakuru_msg_list.append(nkc.Plain(str(component))) + + return nakuru_msg_list + + @staticmethod + def target2yiri(message_chain: typing.Any, message_id: int = -1) -> platform_message.MessageChain: + """将Yiri的消息链转换为YiriMirai的消息链""" + assert type(message_chain) is list + + yiri_msg_list = [] + import datetime + + # 添加Source组件以标记message_id等信息 + yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now())) + for component in message_chain: + if type(component) is nkc.Plain: + yiri_msg_list.append(platform_message.Plain(text=component.text)) + elif type(component) is nkc.Image: + yiri_msg_list.append(platform_message.Image(url=component.url)) + elif type(component) is nkc.At: + yiri_msg_list.append(platform_message.At(target=component.qq)) + elif type(component) is nkc.AtAll: + yiri_msg_list.append(platform_message.AtAll()) + else: + pass + # logging.debug("转换后的消息链: " + str(yiri_msg_list)) + chain = platform_message.MessageChain(yiri_msg_list) + return chain + + +class NakuruProjectEventConverter(abstract_platform_adapter.AbstractEventConverter): + """事件转换器""" + + @staticmethod + def yiri2target(event: typing.Type[platform_events.Event]): + if event is platform_events.GroupMessage: + return nakuru.GroupMessage + elif event is platform_events.FriendMessage: + return nakuru.FriendMessage + else: + raise Exception('未支持转换的事件类型: ' + str(event)) + + @staticmethod + def target2yiri(event: typing.Any) -> platform_events.Event: + yiri_chain = NakuruProjectMessageConverter.target2yiri(event.message, event.message_id) + if type(event) is nakuru.FriendMessage: # 私聊消息事件 + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.sender.user_id, + nickname=event.sender.nickname, + remark=event.sender.nickname, + ), + message_chain=yiri_chain, + time=event.time, + ) + elif type(event) is nakuru.GroupMessage: # 群聊消息事件 + permission = 'MEMBER' + + if event.sender.role == 'admin': + permission = 'ADMINISTRATOR' + elif event.sender.role == 'owner': + permission = 'OWNER' + + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=event.sender.user_id, + member_name=event.sender.nickname, + permission=permission, + group=platform_entities.Group( + id=event.group_id, + name=event.sender.nickname, + permission=platform_entities.Permission.Member, + ), + special_title=event.sender.title, + ), + message_chain=yiri_chain, + time=event.time, + ) + else: + raise Exception('未支持转换的事件类型: ' + str(event)) + + +class NakuruAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """nakuru-project适配器""" + + bot: nakuru.CQHTTP + bot_account_id: int + + message_converter: NakuruProjectMessageConverter = NakuruProjectMessageConverter() + event_converter: NakuruProjectEventConverter = NakuruProjectEventConverter() + + listener_list: list[dict] + + # ap: app.Application + + cfg: dict + + def __init__(self, cfg: dict, ap, logger: EventLogger): + """初始化nakuru-project的对象""" + cfg['port'] = cfg['ws_port'] + del cfg['ws_port'] + self.cfg = cfg + self.ap = ap + self.logger = logger + self.listener_list = [] + self.bot = nakuru.CQHTTP(**self.cfg) + + async def send_message( + self, + target_type: str, + target_id: str, + message: typing.Union[platform_message.MessageChain, list], + converted: bool = False, + ): + task = None + + converted_msg = self.message_converter.yiri2target(message) if not converted else message + + # 检查是否有转发消息 + has_forward = False + for msg in converted_msg: + if type(msg) is list: # 转发消息,仅回复此消息组件 + has_forward = True + converted_msg = msg + break + if has_forward: + if target_type == 'group': + task = self.bot.sendGroupForwardMessage(int(target_id), converted_msg) + elif target_type == 'person': + task = self.bot.sendPrivateForwardMessage(int(target_id), converted_msg) + else: + raise Exception('Unknown target type: ' + target_type) + else: + if target_type == 'group': + task = self.bot.sendGroupMessage(int(target_id), converted_msg) + elif target_type == 'person': + task = self.bot.sendFriendMessage(int(target_id), converted_msg) + else: + raise Exception('Unknown target type: ' + target_type) + + await task + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + message = self.message_converter.yiri2target(message) + if quote_origin: + # 在前方添加引用组件 + message.insert( + 0, + nkc.Reply( + id=message_source.message_chain.message_id, + ), + ) + if type(message_source) is platform_events.GroupMessage: + await self.send_message('group', message_source.sender.group.id, message, converted=True) + elif type(message_source) is platform_events.FriendMessage: + await self.send_message('person', message_source.sender.id, message, converted=True) + else: + raise Exception('Unknown message source type: ' + str(type(message_source))) + + def is_muted(self, group_id: int) -> bool: + import time + + # 检查是否被禁言 + group_member_info = asyncio.run(self.bot.getGroupMemberInfo(group_id, self.bot_account_id)) + return group_member_info.shut_up_timestamp > int(time.time()) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + try: + source_cls = NakuruProjectEventConverter.yiri2target(event_type) + + # 包装函数 + async def listener_wrapper(app: nakuru.CQHTTP, source: source_cls): # type: ignore + await callback(self.event_converter.target2yiri(source), self) + + # 将包装函数和原函数的对应关系存入列表 + self.listener_list.append( + { + 'event_type': event_type, + 'callable': callback, + 'wrapper': listener_wrapper, + } + ) + + # 注册监听器 + self.bot.receiver(source_cls.__name__)(listener_wrapper) + except Exception as e: + self.logger.error(f'Error in nakuru register_listener: {traceback.format_exc()}') + raise e + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + nakuru_event_name = self.event_converter.yiri2target(event_type).__name__ + + new_event_list = [] + + # 从本对象的监听器列表中查找并删除 + target_wrapper = None + for listener in self.listener_list: + if listener['event_type'] == event_type and listener['callable'] == callback: + target_wrapper = listener['wrapper'] + self.listener_list.remove(listener) + break + + if target_wrapper is None: + raise Exception('未找到对应的监听器') + + for func in self.bot.event[nakuru_event_name]: + if func.callable != target_wrapper: + new_event_list.append(func) + + self.bot.event[nakuru_event_name] = new_event_list + + async def run_async(self): + try: + import requests + + resp = requests.get( + url='http://{}:{}/get_login_info'.format(self.cfg['host'], self.cfg['http_port']), + headers={'Authorization': 'Bearer ' + self.cfg['token'] if 'token' in self.cfg else ''}, + timeout=5, + proxies=None, + ) + if resp.status_code == 403: + raise Exception('go-cqhttp拒绝访问,请检查配置文件中nakuru适配器的配置') + self.bot_account_id = int(resp.json()['data']['user_id']) + except Exception: + raise Exception('获取go-cqhttp账号信息失败, 请检查是否已启动go-cqhttp并配置正确') + await self.bot._run() + while True: + await asyncio.sleep(1) + + async def kill(self) -> bool: + return False diff --git a/src/langbot/pkg/platform/sources/legacy/nakuru.yaml b/src/langbot/pkg/platform/sources/legacy/nakuru.yaml new file mode 100644 index 0000000..00d2847 --- /dev/null +++ b/src/langbot/pkg/platform/sources/legacy/nakuru.yaml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: nakuru + label: + en_US: Nakuru + zh_Hans: Nakuru + description: + en_US: Nakuru Adapter + zh_Hans: Nakuru 适配器(go-cqhttp),请查看文档了解使用方式 + icon: nakuru.png +spec: + config: + - name: host + label: + en_US: Host + zh_Hans: 主机 + type: string + required: true + default: "127.0.0.1" + - name: http_port + label: + en_US: HTTP Port + zh_Hans: HTTP端口 + type: integer + required: true + default: 5700 + - name: ws_port + label: + en_US: WebSocket Port + zh_Hans: WebSocket端口 + type: integer + required: true + default: 8080 + - name: token + label: + en_US: Token + zh_Hans: 令牌 + type: string + required: true + default: "" +execution: + python: + path: ./nakuru.py + attr: NakuruAdapter diff --git a/src/langbot/pkg/platform/sources/legacy/qqbotpy.py b/src/langbot/pkg/platform/sources/legacy/qqbotpy.py new file mode 100644 index 0000000..3a55482 --- /dev/null +++ b/src/langbot/pkg/platform/sources/legacy/qqbotpy.py @@ -0,0 +1,524 @@ +from __future__ import annotations + +import logging +import typing +import datetime +import re +import traceback + +import botpy +import botpy.message as botpy_message +import botpy.types.message as botpy_message_type + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from ....pipeline.longtext.strategies import forward +from ....core import app +from ....config import manager as cfg_mgr +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from ...logger import EventLogger + + +class OfficialGroupMessage(platform_events.GroupMessage): + pass + + +class OfficialFriendMessage(platform_events.FriendMessage): + pass + + +event_handler_mapping = { + platform_events.GroupMessage: [ + 'on_at_message_create', + 'on_group_at_message_create', + ], + platform_events.FriendMessage: [ + 'on_direct_message_create', + 'on_c2c_message_create', + ], +} + + +cached_message_ids = {} +"""由于QQ官方的消息id是字符串,而YiriMirai的消息id是整数,所以需要一个索引来进行转换""" + +id_index = 0 + + +def save_msg_id(message_id: str) -> int: + """保存消息id""" + global id_index, cached_message_ids + + crt_index = id_index + id_index += 1 + cached_message_ids[str(crt_index)] = message_id + return crt_index + + +def char_to_value(char): + """将单个字符转换为相应的数值。""" + if '0' <= char <= '9': + return ord(char) - ord('0') + elif 'A' <= char <= 'Z': + return ord(char) - ord('A') + 10 + + return ord(char) - ord('a') + 36 + + +def digest(s: str) -> int: + """计算字符串的hash值。""" + # 取末尾的8位 + sub_s = s[-10:] + + number = 0 + base = 36 + + for i in range(len(sub_s)): + number = number * base + char_to_value(sub_s[i]) + + return number + + +K = typing.TypeVar('K') +V = typing.TypeVar('V') + + +class OpenIDMapping(typing.Generic[K, V]): + map: dict[K, V] + + dump_func: typing.Callable + + digest_func: typing.Callable[[K], V] + + def __init__( + self, + map: dict[K, V], + dump_func: typing.Callable, + digest_func: typing.Callable[[K], V] = digest, + ): + self.map = map + + self.dump_func = dump_func + + self.digest_func = digest_func + + def __getitem__(self, key: K) -> V: + return self.map[key] + + def __setitem__(self, key: K, value: V): + self.map[key] = value + self.dump_func() + + def __contains__(self, key: K) -> bool: + return key in self.map + + def __delitem__(self, key: K): + del self.map[key] + self.dump_func() + + def getkey(self, value: V) -> K: + return list(self.map.keys())[list(self.map.values()).index(value)] + + def save_openid(self, key: K) -> V: + if key in self.map: + return self.map[key] + + value = self.digest_func(key) + + self.map[key] = value + + self.dump_func() + + return value + + +class OfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + """QQ 官方消息转换器""" + + @staticmethod + def yiri2target(message_chain: platform_message.MessageChain): + """将 YiriMirai 的消息链转换为 QQ 官方消息""" + + msg_list = [] + if type(message_chain) is platform_message.MessageChain: + msg_list = message_chain.__root__ + elif type(message_chain) is list: + msg_list = message_chain + elif type(message_chain) is str: + msg_list = [platform_message.Plain(text=message_chain)] + else: + raise Exception('Unknown message type: ' + str(message_chain) + str(type(message_chain))) + + offcial_messages: list[dict] = [] + """ + { + "type": "text", + "content": "Hello World!" + } + + { + "type": "image", + "content": "https://example.com/example.jpg" + } + """ + + # 遍历并转换 + for component in msg_list: + if type(component) is platform_message.Plain: + offcial_messages.append({'type': 'text', 'content': component.text}) + elif type(component) is platform_message.Image: + if component.url is not None: + offcial_messages.append({'type': 'image', 'content': component.url}) + elif component.path is not None: + offcial_messages.append({'type': 'file_image', 'content': component.path}) + elif type(component) is platform_message.At: + offcial_messages.append({'type': 'at', 'content': ''}) + elif type(component) is platform_message.AtAll: + print('上层组件要求发送 AtAll 消息,但 QQ 官方 API 不支持此消息类型,忽略此消息。') + elif type(component) is platform_message.Voice: + print('上层组件要求发送 Voice 消息,但 QQ 官方 API 不支持此消息类型,忽略此消息。') + elif type(component) is forward.Forward: + # 转发消息 + yiri_forward_node_list = component.node_list + + # 遍历并转换 + for yiri_forward_node in yiri_forward_node_list: + try: + message_chain = yiri_forward_node.message_chain + + # 平铺 + offcial_messages.extend(OfficialMessageConverter.yiri2target(message_chain)) + except Exception: + import traceback + + traceback.print_exc() + + return offcial_messages + + @staticmethod + def extract_message_chain_from_obj( + message: typing.Union[ + botpy_message.Message, + botpy_message.DirectMessage, + botpy_message.GroupMessage, + botpy_message.C2CMessage, + ], + message_id: str = None, + bot_account_id: int = 0, + ) -> platform_message.MessageChain: + yiri_msg_list = [] + # 存id + + yiri_msg_list.append(platform_message.Source(id=save_msg_id(message_id), time=datetime.datetime.now())) + + if type(message) not in [botpy_message.DirectMessage, botpy_message.C2CMessage]: + yiri_msg_list.append(platform_message.At(target=bot_account_id)) + + if hasattr(message, 'mentions'): + for mention in message.mentions: + if mention.bot: + continue + + yiri_msg_list.append(platform_message.At(target=mention.id)) + + for attachment in message.attachments: + if attachment.content_type.startswith('image'): + yiri_msg_list.append(platform_message.Image(url=attachment.url)) + else: + logging.warning('不支持的附件类型:' + attachment.content_type + ',忽略此附件。') + + content = re.sub(r'<@!\d+>', '', str(message.content)) + if content.strip() != '': + yiri_msg_list.append(platform_message.Plain(text=content)) + + chain = platform_message.MessageChain(yiri_msg_list) + + return chain + + +class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter): + """事件转换器""" + + def __init__(self): + pass + + def yiri2target(self, event: typing.Type[platform_events.Event]): + if event == platform_events.GroupMessage: + return botpy_message.Message + elif event == platform_events.FriendMessage: + return botpy_message.DirectMessage + else: + raise Exception('未支持转换的事件类型(YiriMirai -> Official): ' + str(event)) + + def target2yiri( + self, + event: typing.Union[ + botpy_message.Message, + botpy_message.DirectMessage, + botpy_message.GroupMessage, + botpy_message.C2CMessage, + ], + ) -> platform_events.Event: + if isinstance(event, botpy_message.Message): # 频道内,转群聊事件 + permission = 'MEMBER' + + if '2' in event.member.roles: + permission = 'ADMINISTRATOR' + elif '4' in event.member.roles: + permission = 'OWNER' + + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=event.author.id, + member_name=event.author.username, + permission=permission, + group=platform_entities.Group( + id=event.channel_id, + name=event.author.username, + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id), + time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), + ) + elif isinstance(event, botpy_message.DirectMessage): # 频道私聊,转私聊事件 + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.guild_id, + nickname=event.author.username, + remark=event.author.username, + ), + message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id), + time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), + ) + elif isinstance(event, botpy_message.GroupMessage): # 群聊,转群聊事件 + author_member_id = event.author.member_openid + + return OfficialGroupMessage( + sender=platform_entities.GroupMember( + id=author_member_id, + member_name=author_member_id, + permission='MEMBER', + group=platform_entities.Group( + id=event.group_openid, + name=author_member_id, + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id), + time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), + ) + elif isinstance(event, botpy_message.C2CMessage): # 私聊,转私聊事件 + user_id_alter = event.author.user_openid + + return OfficialFriendMessage( + sender=platform_entities.Friend( + id=user_id_alter, + nickname=user_id_alter, + remark=user_id_alter, + ), + message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id), + time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), + ) + + +class OfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """QQ 官方消息适配器""" + + bot: botpy.Client = None + + bot_account_id: int = 0 + + message_converter: OfficialMessageConverter + event_converter: OfficialEventConverter + + cfg: dict = None + + cached_official_messages: dict = {} + """缓存的 qq-botpy 框架消息对象 + + message_id: botpy_message.Message | botpy_message.DirectMessage + """ + + ap: app.Application + + metadata: cfg_mgr.ConfigManager = None + + group_msg_seq = None + c2c_msg_seq = None + + def __init__(self, cfg: dict, ap: app.Application, logger: EventLogger): + """初始化适配器""" + self.cfg = cfg + self.ap = ap + self.logger = logger + + self.group_msg_seq = 1 + self.c2c_msg_seq = 1 + + switchs = {} + + for intent in cfg['intents']: + switchs[intent] = True + + del cfg['intents'] + + intents = botpy.Intents(**switchs) + + self.bot = botpy.Client(intents=intents) + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + message_list = self.message_converter.yiri2target(message) + + for msg in message_list: + args = {} + + if msg['type'] == 'text': + args['content'] = msg['content'] + elif msg['type'] == 'image': + args['image'] = msg['content'] + elif msg['type'] == 'file_image': + args['file_image'] = msg['content'] + else: + continue + + if target_type == 'group': + args['channel_id'] = str(target_id) + + await self.bot.api.post_message(**args) + elif target_type == 'person': + args['guild_id'] = str(target_id) + + await self.bot.api.post_dms(**args) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + message_list = self.message_converter.yiri2target(message) + + for msg in message_list: + args = {} + + if msg['type'] == 'text': + args['content'] = msg['content'] + elif msg['type'] == 'image': + args['image'] = msg['content'] + elif msg['type'] == 'file_image': + args['file_image'] = msg['content'] + else: + continue + + if quote_origin: + args['message_reference'] = botpy_message_type.Reference( + message_id=cached_message_ids[str(message_source.message_chain.message_id)] + ) + + if isinstance(message_source, platform_events.GroupMessage): + args['channel_id'] = str(message_source.sender.group.id) + args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)] + await self.bot.api.post_message(**args) + elif isinstance(message_source, platform_events.FriendMessage): + args['guild_id'] = str(message_source.sender.id) + args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)] + await self.bot.api.post_dms(**args) + elif isinstance(message_source, OfficialGroupMessage): + if 'file_image' in args: # 暂不支持发送文件图片 + continue + + args['group_openid'] = message_source.sender.group.id + + if 'image' in args: + uploadMedia = await self.bot.api.post_group_file( + group_openid=args['group_openid'], + file_type=1, + url=str(args['image']), + ) + + del args['image'] + args['media'] = uploadMedia + args['msg_type'] = 7 + + args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)] + args['msg_seq'] = self.group_msg_seq + self.group_msg_seq += 1 + + await self.bot.api.post_group_message(**args) + elif isinstance(message_source, OfficialFriendMessage): + if 'file_image' in args: + continue + args['openid'] = message_source.sender.id + + if 'image' in args: + uploadMedia = await self.bot.api.post_c2c_file( + openid=args['openid'], file_type=1, url=str(args['image']) + ) + + del args['image'] + args['media'] = uploadMedia + args['msg_type'] = 7 + + args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)] + + args['msg_seq'] = self.c2c_msg_seq + self.c2c_msg_seq += 1 + + await self.bot.api.post_c2c_message(**args) + + async def is_muted(self, group_id: int) -> bool: + return False + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + try: + + async def wrapper( + message: typing.Union[ + botpy_message.Message, + botpy_message.DirectMessage, + botpy_message.GroupMessage, + ], + ): + self.cached_official_messages[str(message.id)] = message + await callback(self.event_converter.target2yiri(message), self) + + for event_handler in event_handler_mapping[event_type]: + setattr(self.bot, event_handler, wrapper) + except Exception as e: + self.logger.error(f'Error in qqbotpy callback: {traceback.format_exc()}') + raise e + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + delattr(self.bot, event_handler_mapping[event_type]) + + async def run_async(self): + self.metadata = self.ap.adapter_qq_botpy_meta + + self.message_converter = OfficialMessageConverter() + self.event_converter = OfficialEventConverter() + + self.cfg['ret_coro'] = True + + await self.logger.info('运行 QQ 官方适配器') + await (await self.bot.start(**self.cfg)) + + async def kill(self) -> bool: + if not self.bot.is_closed(): + await self.bot.close() + return True diff --git a/src/langbot/pkg/platform/sources/legacy/qqbotpy.svg b/src/langbot/pkg/platform/sources/legacy/qqbotpy.svg new file mode 100644 index 0000000..d0a07bc --- /dev/null +++ b/src/langbot/pkg/platform/sources/legacy/qqbotpy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/langbot/pkg/platform/sources/legacy/qqbotpy.yaml b/src/langbot/pkg/platform/sources/legacy/qqbotpy.yaml new file mode 100644 index 0000000..e977e31 --- /dev/null +++ b/src/langbot/pkg/platform/sources/legacy/qqbotpy.yaml @@ -0,0 +1,40 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: qq-botpy + label: + en_US: QQBotPy + zh_Hans: QQBotPy + description: + en_US: QQ Official API (WebSocket) + zh_Hans: QQ 官方 API (WebSocket),请查看文档了解使用方式 + icon: qqbotpy.svg +spec: + config: + - name: appid + label: + en_US: App ID + zh_Hans: 应用ID + type: string + required: true + default: "" + - name: secret + label: + en_US: Secret + zh_Hans: 密钥 + type: string + required: true + default: "" + - name: intents + label: + en_US: Intents + zh_Hans: 权限 + type: array + required: true + default: [] + items: + type: string +execution: + python: + path: ./qqbotpy.py + attr: OfficialAdapter diff --git a/src/langbot/pkg/platform/sources/line.png b/src/langbot/pkg/platform/sources/line.png new file mode 100644 index 0000000..c11c022 Binary files /dev/null and b/src/langbot/pkg/platform/sources/line.png differ diff --git a/src/langbot/pkg/platform/sources/line.py b/src/langbot/pkg/platform/sources/line.py new file mode 100644 index 0000000..3d0f75c --- /dev/null +++ b/src/langbot/pkg/platform/sources/line.py @@ -0,0 +1,269 @@ +import typing +import quart + + +import traceback +import asyncio +import base64 +import datetime + + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +from ..logger import EventLogger + + +from linebot.v3 import WebhookHandler +from linebot.v3.exceptions import InvalidSignatureError +from linebot.v3.messaging import Configuration, ApiClient, MessagingApi, ReplyMessageRequest, TextMessage, ImageMessage +from linebot.v3.webhooks import ( + MessageEvent, + TextMessageContent, + ImageMessageContent, + VideoMessageContent, + AudioMessageContent, +) + +# from linebot import WebhookParser +from linebot.v3.webhook import WebhookParser +from linebot.v3.messaging import MessagingApiBlob + + +class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain, api_client: ApiClient) -> typing.Tuple[list]: + content_list = [] + for component in message_chain: + if isinstance(component, platform_message.At): + content_list.append({'type': 'at', 'target': component.target}) + elif isinstance(component, platform_message.Plain): + content_list.append({'type': 'text', 'content': component.text}) + elif isinstance(component, platform_message.Image): + # Only add image if it has a valid URL + if component.url: + content_list.append({'type': 'image', 'image': component.url}) + elif isinstance(component, platform_message.Voice): + content_list.append({'type': 'voice', 'url': component.url, 'length': component.length}) + + return content_list + + @staticmethod + async def target2yiri(message, bot_client) -> platform_message.MessageChain: + lb_msg_list = [] + msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000) + + lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time)) + + if isinstance(message.message, TextMessageContent): + lb_msg_list.append(platform_message.Plain(text=message.message.text)) + elif isinstance(message.message, AudioMessageContent): + pass + elif isinstance(message.message, VideoMessageContent): + pass + elif isinstance(message.message, ImageMessageContent): + message_content = MessagingApiBlob(bot_client).get_message_content(message.message.id) + + base64_string = base64.b64encode(message_content).decode('utf-8') + + # 如果需要Data URI格式(用于直接嵌入HTML等) + # 首先需要知道图片类型,LINE图片通常是JPEG + data_uri = f'data:image/jpeg;base64,{base64_string}' + lb_msg_list.append(platform_message.Image(base64=data_uri)) + return platform_message.MessageChain(lb_msg_list) + + +class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target( + event: platform_events.MessageEvent, + ) -> MessageEvent: + pass + + @staticmethod + async def target2yiri(event, bot_client) -> platform_events.Event: + message_chain = await LINEMessageConverter.target2yiri(event, bot_client) + + if event.source.type == 'user': + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.message.id, + nickname=event.source.user_id, + remark='', + ), + message_chain=message_chain, + time=event.timestamp, + source_platform_object=event, + ) + else: + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=event.event.sender.sender_id.open_id, + member_name=event.event.sender.sender_id.union_id, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=event.message.id, + name='', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=event.timestamp, + source_platform_object=event, + ) + + +class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + bot: MessagingApi + api_client: ApiClient + parser: WebhookParser + + bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识 + message_converter: LINEMessageConverter + event_converter: LINEEventConverter + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] + + config: dict + bot_uuid: str = None + + card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片 + + seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识 + + def __init__(self, config: dict, logger: EventLogger): + configuration = Configuration(access_token=config['channel_access_token']) + line_webhook = WebhookHandler(config['channel_secret']) + parser = WebhookParser(config['channel_secret']) + api_client = ApiClient(configuration) + + bot_account_id = config.get('bot_account_id', 'langbot') + + super().__init__( + config=config, + logger=logger, + listeners={}, + card_id_dict={}, + seq=1, + event_converter=LINEEventConverter(), + message_converter=LINEMessageConverter(), + line_webhook=line_webhook, + parser=parser, + configuration=configuration, + api_client=api_client, + bot=MessagingApi(api_client), + bot_account_id=bot_account_id, + ) + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + pass + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + content_list = await self.message_converter.yiri2target(message, self.api_client) + + for content in content_list: + if content['type'] == 'text': + self.bot.reply_message_with_http_info( + ReplyMessageRequest( + reply_token=message_source.source_platform_object.reply_token, + messages=[TextMessage(text=content['content'])], + ) + ) + elif content['type'] == 'image': + # LINE ImageMessage requires original_content_url and preview_image_url + image_url = content['image'] + self.bot.reply_message_with_http_info( + ReplyMessageRequest( + reply_token=message_source.source_platform_object.reply_token, + messages=[ImageMessage(original_content_url=image_url, preview_image_url=image_url)], + ) + ) + + async def is_muted(self, group_id: int) -> bool: + return False + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners.pop(event_type) + + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + """处理统一 webhook 请求。 + + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) + request: Quart Request 对象 + + Returns: + 响应数据 + """ + try: + signature = request.headers.get('X-Line-Signature') + body = await request.get_data(as_text=True) + + # Check if signature header exists + if not signature: + await self.logger.warning('Missing X-Line-Signature header') + return quart.Response('Missing X-Line-Signature header', status=400) + + try: + events = self.parser.parse(body, signature) # 解密解析消息 + except InvalidSignatureError: + await self.logger.info( + f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}' + ) + return quart.Response('Invalid signature', status=400) + + # 处理事件 + if events and len(events) > 0: + lb_event = await self.event_converter.target2yiri(events[0], self.api_client) + if lb_event.__class__ in self.listeners: + await self.listeners[lb_event.__class__](lb_event, self) + + return {'code': 200, 'message': 'ok'} + except Exception: + await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}') + print(traceback.format_exc()) + return {'code': 500, 'message': 'error'} + + async def run_async(self): + # 统一 webhook 模式下,不启动独立的 Quart 应用 + # 保持运行但不启动独立端口 + + # 打印 webhook 回调地址 + async def keep_alive(): + while True: + await asyncio.sleep(1) + + await keep_alive() + + async def kill(self) -> bool: + pass diff --git a/src/langbot/pkg/platform/sources/line.yaml b/src/langbot/pkg/platform/sources/line.yaml new file mode 100644 index 0000000..5ee5381 --- /dev/null +++ b/src/langbot/pkg/platform/sources/line.yaml @@ -0,0 +1,84 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: LINE + label: + en_US: LINE + zh_Hans: LINE + zh_Hant: LINE + th_TH: LINE + vi_VN: LINE + es_ES: LINE + description: + en_US: LINE Adapter, requires a public URL to receive LINE message pushes, please refer to the documentation for usage details + zh_Hans: LINE适配器,需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式 + zh_Hant: LINE 適配器,需要公網地址以接收 LINE 訊息推送,請查看文件了解使用方式 + ja_JP: LINEアダプター、LINEのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。 + th_TH: อะแดปเตอร์ LINE ต้องการ URL สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก LINE โปรดดูเอกสารประกอบสำหรับรายละเอียดการใช้งาน + vi_VN: Bộ điều hợp LINE, cần URL công cộng để nhận thông báo tin nhắn LINE, vui lòng xem tài liệu để biết chi tiết cách sử dụng + es_ES: Adaptador de LINE, requiere una URL pública para recibir notificaciones de mensajes de LINE, consulte la documentación para obtener detalles de uso + icon: line.png +spec: + categories: + - global + help_links: + zh: https://link.langbot.app/zh/platforms/line + en: https://link.langbot.app/en/platforms/line + ja: https://link.langbot.app/ja/platforms/line + config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + ja_JP: Webhook コールバック URL + zh_Hant: Webhook 回調地址 + th_TH: URL การเรียกกลับ Webhook + vi_VN: URL gọi lại Webhook + es_ES: URL de devolución de llamada Webhook + description: + en_US: Copy this URL and paste it into your LINE channel's webhook configuration + zh_Hans: 复制此地址并粘贴到 LINE 频道的 Webhook 配置中 + ja_JP: この URL をコピーして LINE チャンネルの Webhook 設定に貼り付けてください + zh_Hant: 複製此地址並貼到 LINE 頻道的 Webhook 設定中 + th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่า Webhook ของช่อง LINE ของคุณ + vi_VN: Sao chép URL này và dán vào cấu hình webhook của kênh LINE của bạn + es_ES: Copie esta URL y péguela en la configuración de webhook de su canal LINE + type: webhook-url + required: false + default: "" + - name: channel_access_token + label: + en_US: Channel access token + zh_Hans: 频道访问令牌 + ja_JP: チャンネルアクセストークン + zh_Hant: 頻道存取令牌 + th_TH: โทเค็นการเข้าถึงช่อง + vi_VN: Mã truy cập kênh + es_ES: Token de acceso del canal + type: string + required: true + default: "" + - name: channel_secret + label: + en_US: Channel secret + zh_Hans: 消息密钥 + ja_JP: チャンネルシークレット + zh_Hant: 訊息密鑰 + th_TH: รหัสลับช่อง + vi_VN: Khóa bí mật kênh + es_ES: Secreto del canal + description: + en_US: Only valid when webhook mode is enabled, please fill in the encrypt key + zh_Hans: 请填写加密密钥 + ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください + zh_Hant: 請填寫加密密鑰 + th_TH: กรุณากรอกคีย์เข้ารหัส + vi_VN: Vui lòng điền khóa mã hóa + es_ES: Por favor, introduzca la clave de cifrado + type: string + required: true + default: "" +execution: + python: + path: ./line.py + attr: LINEAdapter \ No newline at end of file diff --git a/src/langbot/pkg/platform/sources/matrix.png b/src/langbot/pkg/platform/sources/matrix.png new file mode 100644 index 0000000..56eb45e Binary files /dev/null and b/src/langbot/pkg/platform/sources/matrix.png differ diff --git a/src/langbot/pkg/platform/sources/matrix.py b/src/langbot/pkg/platform/sources/matrix.py new file mode 100644 index 0000000..da15922 --- /dev/null +++ b/src/langbot/pkg/platform/sources/matrix.py @@ -0,0 +1,693 @@ +from __future__ import annotations + +import typing +import asyncio +import traceback +import base64 +import json + +import nio + +from langbot.pkg.utils import httpclient +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + + +class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain, client: nio.AsyncClient) -> list[dict]: + components = [] + for component in message_chain: + if isinstance(component, platform_message.Plain): + components.append({'type': 'text', 'text': component.text}) + elif isinstance(component, platform_message.Image): + image_bytes = None + if component.base64: + b64_data = component.base64 + if ';base64,' in b64_data: + b64_data = b64_data.split(';base64,', 1)[1] + image_bytes = base64.b64decode(b64_data) + elif component.url: + session = httpclient.get_session() + async with session.get(component.url) as response: + image_bytes = await response.read() + elif component.path: + with open(component.path, 'rb') as f: + image_bytes = f.read() + if image_bytes: + resp = await client.upload(image_bytes, content_type='image/png') + if isinstance(resp, nio.UploadResponse): + components.append({'type': 'image', 'mxc_url': resp.content_uri}) + elif isinstance(component, platform_message.File): + file_bytes = None + if component.base64: + b64_data = component.base64 + if ';base64,' in b64_data: + b64_data = b64_data.split(';base64,', 1)[1] + file_bytes = base64.b64decode(b64_data) + elif component.url: + session = httpclient.get_session() + async with session.get(component.url) as response: + file_bytes = await response.read() + elif component.path: + with open(component.path, 'rb') as f: + file_bytes = f.read() + if file_bytes: + file_name = getattr(component, 'name', None) or 'file' + resp = await client.upload(file_bytes, content_type='application/octet-stream', filename=file_name) + if isinstance(resp, nio.UploadResponse): + components.append( + { + 'type': 'file', + 'mxc_url': resp.content_uri, + 'filename': file_name, + 'size': len(file_bytes), + } + ) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + components.extend(await MatrixMessageConverter.yiri2target(node.message_chain, client)) + return components + + @staticmethod + async def target2yiri(event: nio.RoomMessageText | nio.RoomMessageImage, client: nio.AsyncClient, bot_user_id: str): + message_components = [] + + if isinstance(event, nio.RoomMessageText): + text = event.body + if bot_user_id and bot_user_id in text: + message_components.append(platform_message.At(target=bot_user_id)) + text = text.replace(bot_user_id, '').strip() + message_components.append(platform_message.Plain(text=text)) + + elif isinstance(event, nio.RoomMessageImage): + mxc_url = event.url + if mxc_url: + resp = await client.download(mxc_url) + if isinstance(resp, nio.DownloadResponse): + b64 = base64.b64encode(resp.body).decode('utf-8') + content_type = resp.content_type or 'image/png' + message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}')) + if event.body: + message_components.append(platform_message.Plain(text=event.body)) + + return platform_message.MessageChain(message_components) + + +class MatrixEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.MessageEvent): + return event.source_platform_object + + @staticmethod + async def target2yiri( + event: nio.RoomMessageText | nio.RoomMessageImage, + room: nio.MatrixRoom, + client: nio.AsyncClient, + bot_user_id: str, + bridge_user_ids: list[str] | None = None, + ): + lb_message = await MatrixMessageConverter.target2yiri(event, client, bot_user_id) + + # Determine if this is a direct/private chat or a group chat. + # Exclude bot itself and bridge bots, count remaining real users. + exclude_ids = {bot_user_id} + if bridge_user_ids: + exclude_ids.update(bridge_user_ids) + real_users = [uid for uid in room.users if uid not in exclude_ids] + is_direct = len(real_users) <= 1 + + if is_direct: + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.sender, + nickname=room.user_name(event.sender) or event.sender, + remark='', + ), + message_chain=lb_message, + time=event.server_timestamp / 1000.0, + source_platform_object={'event': event, 'room': room}, + ) + else: + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=event.sender, + member_name=room.user_name(event.sender) or event.sender, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=room.room_id, + name=room.display_name or room.room_id, + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=lb_message, + time=event.server_timestamp / 1000.0, + source_platform_object={'event': event, 'room': room}, + ) + + +class BridgeState: + """Per-bridge runtime state.""" + + def __init__(self, user_id: str, login_command: str, logout_command: str, success_keyword: str, check_command: str): + self.user_id = user_id + self.login_command = login_command + self.logout_command = logout_command + self.success_keyword = success_keyword + self.check_command = check_command or login_command + self.logged_in = False + self.dm_room_id: str | None = None + self.login_task: asyncio.Task | None = None + self.check_task: asyncio.Task | None = None + self.check_responded = False + + +class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + client: typing.Any = None + message_converter: MatrixMessageConverter = MatrixMessageConverter() + event_converter: MatrixEventConverter = MatrixEventConverter() + config: dict + listeners: typing.Dict[typing.Type[platform_events.Event], typing.Callable] = {} + _running: bool = False + _initial_sync_done: bool = False + _bridges: list = [] + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + homeserver_url = config.get('homeserver_url', '') + access_token = config.get('access_token', '') + user_id = config.get('user_id', '') + + if not homeserver_url or not access_token or not user_id: + raise ValueError('Matrix 机器人缺少必要配置项 (homeserver_url, user_id, access_token)') + + client = nio.AsyncClient(homeserver_url, user_id) + client.access_token = access_token + client.user_id = user_id + + super().__init__( + config=config, + logger=logger, + bot_account_id=user_id, + client=client, + listeners={}, + ) + + # Parse bridges config AFTER super().__init__() to avoid Pydantic resetting _bridges + self._bridges = [] + bridges_raw = config.get('bridges', '') + if bridges_raw: + if isinstance(bridges_raw, str): + try: + bridges_list = json.loads(bridges_raw) + except (json.JSONDecodeError, TypeError) as e: + raise ValueError(f'bridges 配置 JSON 解析失败: {e}\n原始值: {bridges_raw}') + else: + bridges_list = bridges_raw + for b in bridges_list: + if isinstance(b, dict) and b.get('user_id', '').strip(): + self._bridges.append( + BridgeState( + user_id=b['user_id'].strip(), + login_command=b.get('login_command', '').strip(), + logout_command=b.get('logout_command', '').strip(), + success_keyword=b.get('success_keyword', 'Successfully logged in').strip(), + check_command=b.get('check_command', '').strip(), + ) + ) + # Backward compatibility: old single-bridge config + if not self._bridges: + old_user_id = config.get('bridge_user_id', '').strip() + old_command = config.get('bridge_login_command', '').strip() + old_keyword = config.get('bridge_login_success_keyword', 'Successfully logged in').strip() + old_check = config.get('bridge_check_command', '').strip() + old_logout = config.get('bridge_logout_command', '').strip() + if old_user_id: + self._bridges.append( + BridgeState( + user_id=old_user_id, + login_command=old_command, + logout_command=old_logout, + success_keyword=old_keyword, + check_command=old_check, + ) + ) + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + components = await self.message_converter.yiri2target(message, self.client) + for component in components: + await self._send_component(target_id, component) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + source_obj = message_source.source_platform_object + room_id = source_obj['room'].room_id + components = await self.message_converter.yiri2target(message, self.client) + + for component in components: + if quote_origin: + original_event = source_obj['event'] + await self._send_component(room_id, component, reply_to=original_event.event_id) + else: + await self._send_component(room_id, component) + + async def _send_component(self, room_id: str, component: dict, reply_to: str | None = None): + content = {} + if component['type'] == 'text': + content = { + 'msgtype': 'm.text', + 'body': component['text'], + } + elif component['type'] == 'image': + content = { + 'msgtype': 'm.image', + 'body': 'image.png', + 'url': component['mxc_url'], + } + elif component['type'] == 'file': + content = { + 'msgtype': 'm.file', + 'body': component.get('filename', 'file'), + 'url': component['mxc_url'], + 'info': {'size': component.get('size', 0)}, + } + + if reply_to and content: + content['m.relates_to'] = { + 'm.in_reply_to': {'event_id': reply_to}, + } + + if content: + await self.client.room_send( + room_id=room_id, + message_type='m.room.message', + content=content, + ) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + async def run_async(self): + self._running = True + await self.logger.info('Matrix adapter starting...') + + # Debug: log bridge parsing result + bridges_raw = self.config.get('bridges', '') + await self.logger.debug(f'bridges config raw: type={type(bridges_raw).__name__}, repr={repr(bridges_raw)}') + await self.logger.debug( + f'parsed _bridges count: {len(self._bridges)}, ids: {[b.user_id for b in self._bridges]}' + ) + + # Collect all bridge bot user IDs for filtering + _bridge_user_ids = [b.user_id for b in self._bridges] + _bridge_user_id_set = set(_bridge_user_ids) + + # Auto-join invited rooms + async def on_invite(room: nio.MatrixRoom, event: nio.InviteMemberEvent): + if event.membership == 'invite' and event.state_key == self.client.user_id: + await self.client.join(room.room_id) + await self.logger.debug(f'Auto-joined room: {room.display_name or room.room_id}') + + self.client.add_event_callback(on_invite, nio.InviteMemberEvent) + + # Handle text messages + async def on_message(room: nio.MatrixRoom, event: nio.RoomMessageText): + if not self._initial_sync_done: + return + if event.sender == self.client.user_id: + return + + # Admin commands (from any non-bridge user) + if event.sender not in _bridge_user_id_set: + body = (event.body or '').strip() + if body == '!relogin': + await self._handle_relogin_command(room.room_id) + return + if body == '!status': + await self._handle_status_command(room.room_id) + return + + if event.sender in _bridge_user_id_set: + return + try: + lb_event = await self.event_converter.target2yiri( + event, room, self.client, self.bot_account_id, _bridge_user_ids + ) + if type(lb_event) in self.listeners: + result = self.listeners[type(lb_event)](lb_event, self) + if asyncio.iscoroutine(result): + await result + except Exception: + await self.logger.error(f'Error handling Matrix message: {traceback.format_exc()}') + + self.client.add_event_callback(on_message, nio.RoomMessageText) + + # Handle image messages + async def on_image(room: nio.MatrixRoom, event: nio.RoomMessageImage): + if not self._initial_sync_done: + return + if event.sender == self.client.user_id: + return + if event.sender in _bridge_user_id_set: + return + try: + lb_event = await self.event_converter.target2yiri( + event, room, self.client, self.bot_account_id, _bridge_user_ids + ) + if type(lb_event) in self.listeners: + result = self.listeners[type(lb_event)](lb_event, self) + if asyncio.iscoroutine(result): + await result + except Exception: + await self.logger.error(f'Error handling Matrix image: {traceback.format_exc()}') + + self.client.add_event_callback(on_image, nio.RoomMessageImage) + + # Set up bridge-specific callbacks for each bridge + _disconnect_keywords = ['disconnected', 'logged out', 'connection lost', 'session expired', 'token expired'] + + for bridge in self._bridges: + # Login success detection (notice) + async def on_bridge_notice(room: nio.MatrixRoom, event: nio.RoomMessageNotice, _b=bridge): + if not self._initial_sync_done: + return + if event.sender != _b.user_id: + return + _b.check_responded = True + if _b.success_keyword in (event.body or ''): + _b.logged_in = True + await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.') + # Disconnect detection + body_lower = (event.body or '').lower() + for kw in _disconnect_keywords: + if kw in body_lower and _b.logged_in: + _b.logged_in = False + await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...') + self._restart_bridge_login(_b) + break + + self.client.add_event_callback(on_bridge_notice, nio.RoomMessageNotice) + + # Login success + disconnect detection (text) + async def on_bridge_text(room: nio.MatrixRoom, event: nio.RoomMessageText, _b=bridge): + if not self._initial_sync_done: + return + if event.sender != _b.user_id: + return + _b.check_responded = True + if _b.success_keyword in (event.body or ''): + _b.logged_in = True + await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.') + body_lower = (event.body or '').lower() + for kw in _disconnect_keywords: + if kw in body_lower and _b.logged_in: + _b.logged_in = False + await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...') + self._restart_bridge_login(_b) + break + + self.client.add_event_callback(on_bridge_text, nio.RoomMessageText) + + # QR code image forwarding + async def on_bridge_image(room: nio.MatrixRoom, event: nio.RoomMessageImage, _b=bridge): + if not self._initial_sync_done: + return + if event.sender != _b.user_id: + return + mxc_url = event.url + if not mxc_url: + return + try: + resp = await self.client.download(mxc_url) + if isinstance(resp, nio.DownloadResponse): + b64 = base64.b64encode(resp.body).decode('utf-8') + content_type = resp.content_type or 'image/png' + await self.logger.info( + f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:', + images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')], + ) + except Exception: + await self.logger.error( + f'[{_b.user_id}] Failed to download bridge QR image: {traceback.format_exc()}' + ) + + self.client.add_event_callback(on_bridge_image, nio.RoomMessageImage) + + await self.logger.debug('Matrix adapter running, starting sync...') + + # Initial sync to skip old messages + resp = await self.client.sync(timeout=10000) + if isinstance(resp, nio.SyncResponse): + await self.logger.debug(f'Matrix initial sync done, next_batch: {resp.next_batch}') + self._initial_sync_done = True + + # Display account info + display_name = self.client.user_id + try: + profile_resp = await self.client.get_displayname(self.client.user_id) + if isinstance(profile_resp, nio.ProfileGetDisplayNameResponse) and profile_resp.displayname: + display_name = profile_resp.displayname + except Exception: + pass + joined_rooms = len(self.client.rooms) + homeserver = self.config.get('homeserver_url', '') + bridge_info = '' + if self._bridges: + bridge_names = ', '.join(b.user_id for b in self._bridges) + bridge_info = f' | 桥接: [{bridge_names}]' + await self.logger.info( + f'Matrix 账号: {display_name} ({self.client.user_id}) | ' + f'服务器: {homeserver} | 已加入 {joined_rooms} 个房间{bridge_info}' + ) + + # Start bridge login and status check tasks for each bridge + for bridge in self._bridges: + if bridge.login_command: + await self.logger.info( + f'[{bridge.user_id}] Bridge login enabled (命令: "{bridge.login_command}", ' + f'关键词: "{bridge.success_keyword}")' + ) + bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge)) + bridge.check_task = asyncio.create_task(self._periodic_bridge_check(bridge)) + else: + await self.logger.debug(f'[{bridge.user_id}] Bridge login not configured (no login_command)') + + # Main sync loop + while self._running: + try: + await self.client.sync(timeout=30000) + except Exception: + await self.logger.error(f'Matrix sync error: {traceback.format_exc()}') + await asyncio.sleep(5) + + async def _periodic_bridge_login(self, bridge: BridgeState): + """Periodically send login command to a bridge bot until login succeeds.""" + try: + await self.logger.info(f'[{bridge.user_id}] Bridge login task started, looking for DM room...') + dm_room_id = None + for room_id, room in self.client.rooms.items(): + if room.member_count == 2 and bridge.user_id in [m for m in room.users]: + dm_room_id = room_id + break + + if not dm_room_id: + resp = await self.client.room_create( + is_direct=True, + invite=[bridge.user_id], + ) + if isinstance(resp, nio.RoomCreateResponse): + dm_room_id = resp.room_id + await self.logger.debug(f'[{bridge.user_id}] Created DM room: {dm_room_id}') + else: + await self.logger.error(f'[{bridge.user_id}] Failed to create DM room: {resp}') + return + + bridge.dm_room_id = dm_room_id + + # Force logout first on every adapter start + logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') + await self.logger.info(f'[{bridge.user_id}] 强制登出: "{logout_cmd}"') + await self.client.room_send( + room_id=dm_room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': logout_cmd}, + ) + await asyncio.sleep(3) + + while self._running and not bridge.logged_in: + await self.logger.debug(f'[{bridge.user_id}] Sending "{bridge.login_command}" in room {dm_room_id}') + await self.client.room_send( + room_id=dm_room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': bridge.login_command}, + ) + for _ in range(60): + if not self._running or bridge.logged_in: + break + await asyncio.sleep(1) + + if bridge.logged_in: + await self.logger.debug(f'[{bridge.user_id}] Bridge login confirmed, periodic login stopped.') + except asyncio.CancelledError: + pass + except Exception: + await self.logger.error(f'[{bridge.user_id}] Bridge periodic login error: {traceback.format_exc()}') + + def _restart_bridge_login(self, bridge: BridgeState): + """Cancel existing login task and start a new one.""" + if bridge.login_task and not bridge.login_task.done(): + bridge.login_task.cancel() + bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge)) + + async def _periodic_bridge_check(self, bridge: BridgeState): + """Periodically check a bridge's login status.""" + try: + while self._running and not bridge.logged_in: + await asyncio.sleep(5) + + check_interval = 300 # 5 minutes + response_timeout = 30 + await self.logger.debug(f'[{bridge.user_id}] Bridge status check started (interval: {check_interval}s)') + + while self._running: + for _ in range(check_interval): + if not self._running: + return + await asyncio.sleep(1) + + if not bridge.logged_in or not bridge.dm_room_id: + continue + + try: + bridge.check_responded = False + await self.client.room_send( + room_id=bridge.dm_room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': bridge.check_command}, + ) + await self.logger.debug(f'[{bridge.user_id}] Bridge status check: sent "{bridge.check_command}"') + + for _ in range(response_timeout): + if bridge.check_responded or not self._running: + break + await asyncio.sleep(1) + + if bridge.check_responded: + await self.logger.debug(f'[{bridge.user_id}] Bridge status check: OK') + else: + await self.logger.info( + f'[{bridge.user_id}] Bridge status check: 无响应, 可能已掉线, 尝试重新登录...' + ) + bridge.logged_in = False + self._restart_bridge_login(bridge) + except Exception: + await self.logger.error(f'[{bridge.user_id}] Bridge status check error: {traceback.format_exc()}') + except asyncio.CancelledError: + pass + except Exception: + await self.logger.error(f'[{bridge.user_id}] Bridge status check fatal error: {traceback.format_exc()}') + + async def _handle_relogin_command(self, room_id: str): + """Handle !relogin command: logout then re-login all bridges.""" + if not self._bridges: + await self.client.room_send( + room_id=room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': '没有配置任何桥。'}, + ) + return + + lines = ['开始重新登录所有桥...'] + for bridge in self._bridges: + if not bridge.login_command or not bridge.dm_room_id: + lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)') + continue + + # Use configured logout command, fallback to deriving from login command + logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') + lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...') + + # Cancel existing tasks + if bridge.login_task and not bridge.login_task.done(): + bridge.login_task.cancel() + if bridge.check_task and not bridge.check_task.done(): + bridge.check_task.cancel() + + # Send logout + try: + await self.client.room_send( + room_id=bridge.dm_room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': logout_cmd}, + ) + except Exception as e: + lines.append(f'[{bridge.user_id}] logout 发送失败: {e}') + + await asyncio.sleep(2) + + # Reset state and restart login + bridge.logged_in = False + self._restart_bridge_login(bridge) + lines.append(f'[{bridge.user_id}] 已触发重新登录') + + await self.client.room_send( + room_id=room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': '\n'.join(lines)}, + ) + + async def _handle_status_command(self, room_id: str): + """Handle !status command: show bridge states.""" + if not self._bridges: + await self.client.room_send( + room_id=room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': '没有配置任何桥。'}, + ) + return + + lines = ['桥状态:'] + for bridge in self._bridges: + status = '已登录 ✓' if bridge.logged_in else '未登录 ✗' + dm = bridge.dm_room_id or '无' + lines.append(f'• {bridge.user_id}: {status} (DM: {dm})') + await self.client.room_send( + room_id=room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': '\n'.join(lines)}, + ) + + async def kill(self) -> bool: + self._running = False + for bridge in self._bridges: + if bridge.login_task and not bridge.login_task.done(): + bridge.login_task.cancel() + if bridge.check_task and not bridge.check_task.done(): + bridge.check_task.cancel() + if self.client: + await self.client.close() + await self.logger.debug('Matrix adapter stopped') + return True + + async def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + if event_type in self.listeners: + del self.listeners[event_type] diff --git a/src/langbot/pkg/platform/sources/matrix.yaml b/src/langbot/pkg/platform/sources/matrix.yaml new file mode 100644 index 0000000..d69b605 --- /dev/null +++ b/src/langbot/pkg/platform/sources/matrix.yaml @@ -0,0 +1,123 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: matrix + label: + en_US: Matrix + zh_Hans: Matrix + zh_Hant: Matrix + ja_JP: Matrix + th_TH: Matrix + vi_VN: Matrix + es_ES: Matrix + description: + en_US: Matrix protocol adapter, supports self-hosted Synapse servers and any Matrix-compatible homeserver + zh_Hans: Matrix 协议适配器,支持自建 Synapse 服务器及任何 Matrix 兼容的 Homeserver + zh_Hant: Matrix 協議適配器,支持自建 Synapse 伺服器及任何 Matrix 相容的 Homeserver + ja_JP: Matrix プロトコルアダプター、セルフホストの Synapse サーバーおよび Matrix 互換のホームサーバーをサポート + th_TH: อะแดปเตอร์โปรโตคอล Matrix รองรับเซิร์ฟเวอร์ Synapse ที่โฮสต์เองและ Homeserver ที่เข้ากันได้กับ Matrix + vi_VN: Bộ điều hợp giao thức Matrix, hỗ trợ máy chủ Synapse tự lưu trữ và bất kỳ Homeserver tương thích Matrix nào + es_ES: Adaptador del protocolo Matrix, compatible con servidores Synapse autoalojados y cualquier Homeserver compatible con Matrix + icon: matrix.png +spec: + categories: + - global + - protocol + config: + - name: homeserver_url + label: + en_US: Homeserver URL + zh_Hans: Homeserver 地址 + zh_Hant: Homeserver 地址 + ja_JP: Homeserver URL + th_TH: URL ของ Homeserver + vi_VN: URL Homeserver + es_ES: URL del Homeserver + description: + en_US: "The URL of the Matrix homeserver, e.g. http://localhost:8008" + zh_Hans: "Matrix Homeserver 的地址,例如 http://localhost:8008" + type: string + required: true + default: "http://localhost:8008" + - name: user_id + label: + en_US: Bot User ID + zh_Hans: 机器人用户 ID + zh_Hant: 機器人用戶 ID + ja_JP: ボットユーザー ID + th_TH: ID ผู้ใช้บอท + vi_VN: ID người dùng bot + es_ES: ID de usuario del bot + description: + en_US: "The full Matrix user ID, e.g. @bot:localhost" + zh_Hans: "完整的 Matrix 用户 ID,例如 @bot:localhost" + type: string + required: true + default: "@langbot:localhost" + - name: access_token + label: + en_US: Access Token + zh_Hans: 访问令牌 + zh_Hant: 訪問令牌 + ja_JP: アクセストークン + th_TH: โทเค็นการเข้าถึง + vi_VN: Mã truy cập + es_ES: Token de acceso + description: + en_US: "Access token obtained by logging in via the Matrix client API" + zh_Hans: "通过 Matrix Client API 登录获取的访问令牌" + type: string + required: true + default: "" + - name: bridge_user_id + label: + en_US: Bridge Bot User ID (single bridge, legacy) + zh_Hans: 桥机器人用户 ID(单桥兼容) + description: + en_US: "Single bridge bot user ID (legacy). Prefer 'bridges' for multi-bridge. e.g. @discordbot:localhost" + zh_Hans: "单桥机器人用户 ID(旧格式兼容)。推荐使用 bridges 配置多桥。例如 @discordbot:localhost" + type: string + required: false + default: "" + - name: bridge_login_command + label: + en_US: Bridge Login Command (single bridge, legacy) + zh_Hans: 桥登录命令(单桥兼容) + description: + en_US: "Login command for single bridge (legacy). e.g. !discord login" + zh_Hans: "单桥登录命令(旧格式兼容)。例如 !discord login" + type: string + required: false + default: "" + - name: bridge_login_success_keyword + label: + en_US: Bridge Login Success Keyword (single bridge, legacy) + zh_Hans: 桥登录成功关键词(单桥兼容) + description: + en_US: "Success keyword for single bridge (legacy). e.g. Successfully logged in" + zh_Hans: "单桥登录成功关键词(旧格式兼容)。例如 Successfully logged in" + type: string + required: false + default: "Successfully logged in" + - name: bridges + label: + en_US: Bridges Config (Multi-bridge) + zh_Hans: 桥配置(多桥) + description: + en_US: > + JSON array of bridge configs. Each bridge: {"user_id": "@bot:host", "login_command": "!xx login", + "success_keyword": "logged in", "check_command": "!xx ping"}. + Example: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"}, + {"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}] + zh_Hans: > + JSON 数组格式的多桥配置。每个桥: {"user_id": "@bot:host", "login_command": "!xx login", + "success_keyword": "logged in", "check_command": "!xx ping"}。 + 示例: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"}, + {"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}] + type: string + required: false + default: "" +execution: + python: + path: ./matrix.py + attr: MatrixAdapter diff --git a/src/langbot/pkg/platform/sources/officialaccount.png b/src/langbot/pkg/platform/sources/officialaccount.png new file mode 100644 index 0000000..24746e1 Binary files /dev/null and b/src/langbot/pkg/platform/sources/officialaccount.png differ diff --git a/src/langbot/pkg/platform/sources/officialaccount.py b/src/langbot/pkg/platform/sources/officialaccount.py new file mode 100644 index 0000000..288991d --- /dev/null +++ b/src/langbot/pkg/platform/sources/officialaccount.py @@ -0,0 +1,182 @@ +from __future__ import annotations +import typing +import asyncio +import traceback +import pydantic +import datetime +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +from langbot.libs.official_account_api.oaevent import OAEvent +from langbot.libs.official_account_api.api import OAClient +from langbot.libs.official_account_api.api import OAClientForLongerResponse +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +from ..logger import EventLogger + + +class OAMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain): + for msg in message_chain: + if type(msg) is platform_message.Plain: + return msg.text + + @staticmethod + async def target2yiri(message: str, message_id=-1): + yiri_msg_list = [] + yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now())) + + yiri_msg_list.append(platform_message.Plain(text=message)) + chain = platform_message.MessageChain(yiri_msg_list) + + return chain + + +class OAEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def target2yiri(event: OAEvent): + if event.type == 'text': + yiri_chain = await OAMessageConverter.target2yiri(event.message, event.message_id) + + friend = platform_entities.Friend( + id=event.user_id, + nickname=str(event.user_id), + remark='', + ) + + return platform_events.FriendMessage( + sender=friend, + message_chain=yiri_chain, + time=event.timestamp, + source_platform_object=event, + ) + else: + return None + + +class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + message_converter: OAMessageConverter = OAMessageConverter() + event_converter: OAEventConverter = OAEventConverter() + bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True) + bot_uuid: str = None + + def __init__(self, config: dict, logger: EventLogger): + # 校验必填项 + required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode'] + missing_keys = [k for k in required_keys if k not in config] + if missing_keys: + raise Exception(f'OfficialAccount 缺少配置项: {missing_keys}') + + # 创建运行时 bot 对象,始终使用统一 webhook 模式 + if config['Mode'] == 'drop': + bot = OAClient( + token=config['token'], + EncodingAESKey=config['EncodingAESKey'], + Appsecret=config['AppSecret'], + AppID=config['AppID'], + logger=logger, + unified_mode=True, + api_base_url=config.get('api_base_url', 'https://api.weixin.qq.com'), + ) + elif config['Mode'] == 'passive': + bot = OAClientForLongerResponse( + token=config['token'], + EncodingAESKey=config['EncodingAESKey'], + Appsecret=config['AppSecret'], + AppID=config['AppID'], + LoadingMessage=config.get('LoadingMessage', ''), + logger=logger, + unified_mode=True, + api_base_url=config.get('api_base_url', 'https://api.weixin.qq.com'), + ) + else: + raise KeyError('请设置微信公众号通信模式') + + bot_account_id = config.get('AppID', '') + + super().__init__( + bot=bot, + bot_account_id=bot_account_id, + config=config, + logger=logger, + ) + + async def reply_message( + self, + message_source: platform_events.FriendMessage, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + content = await OAMessageConverter.yiri2target(message) + if isinstance(self.bot, OAClient): + await self.bot.set_message(message_source.message_chain.message_id, content) + elif isinstance(self.bot, OAClientForLongerResponse): + from_user = message_source.sender.id + await self.bot.set_message(from_user, message_source.message_chain.message_id, content) + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + pass + + def register_listener( + self, + event_type: type, + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + async def on_message(event: OAEvent): + self.bot_account_id = event.receiver_id + try: + return await callback(await self.event_converter.target2yiri(event), self) + except Exception: + await self.logger.error(f'Error in officialaccount callback: {traceback.format_exc()}') + + if event_type == platform_events.FriendMessage: + self.bot.on_message('text')(on_message) + elif event_type == platform_events.GroupMessage: + pass + + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + """处理统一 webhook 请求。 + + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) + request: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self.bot.handle_unified_webhook(request) + + async def run_async(self): + # 统一 webhook 模式下,不启动独立的 Quart 应用 + # 保持运行但不启动独立端口 + + async def keep_alive(): + while True: + await asyncio.sleep(1) + + await keep_alive() + + async def kill(self) -> bool: + return False + + async def unregister_listener( + self, + event_type: type, + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + return super().unregister_listener(event_type, callback) + + async def is_muted( + self, + group_id: str, + ) -> bool: + pass diff --git a/src/langbot/pkg/platform/sources/officialaccount.yaml b/src/langbot/pkg/platform/sources/officialaccount.yaml new file mode 100644 index 0000000..d095380 --- /dev/null +++ b/src/langbot/pkg/platform/sources/officialaccount.yaml @@ -0,0 +1,108 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: officialaccount + label: + en_US: Official Account + zh_Hans: 微信公众号 + zh_Hant: 微信公眾號 + description: + en_US: Official Account Adapter + zh_Hans: 微信公众号适配器,需要公网地址以接收消息推送,请查看文档了解使用方式 + zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式 + icon: officialaccount.png +spec: + categories: + - china + help_links: + zh: https://link.langbot.app/zh/platforms/officialaccount + en: https://link.langbot.app/en/platforms/officialaccount + ja: https://link.langbot.app/ja/platforms/officialaccount + config: + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL and paste it into your Official Account webhook configuration + zh_Hans: 复制此地址并粘贴到微信公众号的 Webhook 配置中 + zh_Hant: 複製此地址並貼到微信公眾號的 Webhook 設定中 + type: webhook-url + required: false + default: "" + - name: __system.outbound_ips + label: + en_US: IP Whitelist + zh_Hans: IP 白名单 + zh_Hant: IP 白名單 + description: + en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the "Basic Configuration" of the WeChat Official Account platform + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到微信公众平台「基本配置」中的 IP 白名单 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入微信公眾平台「基本配置」中的 IP 白名單 + type: array[string] + required: false + default: [] + - name: token + label: + en_US: Token + zh_Hans: 令牌 + zh_Hant: 令牌 + required: true + default: "" + - name: EncodingAESKey + label: + en_US: EncodingAESKey + zh_Hans: 消息加解密密钥 + zh_Hant: 訊息加解密密鑰 + type: string + required: true + default: "" + - name: AppID + label: + en_US: App ID + zh_Hans: 应用ID + zh_Hant: 應用ID + type: string + required: true + default: "" + - name: AppSecret + label: + en_US: App Secret + zh_Hans: 应用密钥 + zh_Hant: 應用密鑰 + type: string + required: true + default: "" + - name: Mode + label: + en_US: Mode + zh_Hans: 接入模式 + zh_Hant: 接入模式 + type: string + required: true + default: "drop" + - name: LoadingMessage + label: + en_US: Loading Message + zh_Hans: 加载消息 + zh_Hant: 載入訊息 + type: string + required: true + default: "AI正在思考中,请发送任意内容获取回复。" + - name: api_base_url + label: + en_US: API Base URL + zh_Hans: API 基础 URL + zh_Hant: API 基礎 URL + description: + en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation. + zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项 + zh_Hant: 可選,若您部署在內網環境並透過反向代理存取微信公眾號 API,可根據文件修改此項 + type: string + required: false + default: "https://api.weixin.qq.com" +execution: + python: + path: ./officialaccount.py + attr: OfficialAccountAdapter diff --git a/src/langbot/pkg/platform/sources/onebot.png b/src/langbot/pkg/platform/sources/onebot.png new file mode 100644 index 0000000..5144648 Binary files /dev/null and b/src/langbot/pkg/platform/sources/onebot.png differ diff --git a/src/langbot/pkg/platform/sources/openclaw_weixin.py b/src/langbot/pkg/platform/sources/openclaw_weixin.py new file mode 100644 index 0000000..9253f90 --- /dev/null +++ b/src/langbot/pkg/platform/sources/openclaw_weixin.py @@ -0,0 +1,577 @@ +"""OpenClaw WeChat adapter for LangBot. + +Uses the OpenClaw WeChat HTTP JSON API (long-poll getUpdates + sendMessage) +to integrate personal WeChat accounts with LangBot. + +Reference: https://github.com/epiral/weixin-bot +""" + +from __future__ import annotations + +import asyncio +import base64 +import traceback +import typing + +import pydantic +import sqlalchemy + +from langbot.libs.openclaw_weixin_api.client import ( + DEFAULT_BASE_URL, + SESSION_EXPIRED_ERRCODE, + OpenClawWeixinClient, +) +from langbot.libs.openclaw_weixin_api.types import ( + MessageItem, + WeixinMessage, +) +from langbot.pkg.entity.persistence import bot as persistence_bot + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + """Converts between LangBot MessageChain and OpenClaw WeChat message items.""" + + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]: + """Convert LangBot MessageChain to a list of OpenClaw message item dicts.""" + items = [] + for component in message_chain: + if isinstance(component, platform_message.Plain): + items.append({'type': MessageItem.TEXT, 'text_item': {'text': component.text}}) + elif isinstance(component, platform_message.Image): + # OpenClaw WeChat only supports text messages without CDN upload. + # For images, we send a placeholder text with the URL if available. + if component.url: + items.append( + { + 'type': MessageItem.TEXT, + 'text_item': {'text': f'[Image: {component.url}]'}, + } + ) + elif component.base64: + items.append( + { + 'type': MessageItem.TEXT, + 'text_item': {'text': '[Image]'}, + } + ) + elif isinstance(component, platform_message.File): + if component.name: + items.append( + { + 'type': MessageItem.TEXT, + 'text_item': {'text': f'[File: {component.name}]'}, + } + ) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + items.extend(await OpenClawWeixinMessageConverter.yiri2target(node.message_chain)) + return items + + @staticmethod + async def target2yiri( + msg: WeixinMessage, + ) -> platform_message.MessageChain: + """Convert an OpenClaw WeixinMessage to LangBot MessageChain.""" + components: list[platform_message.MessageComponent] = [] + + if not msg.item_list: + return platform_message.MessageChain(components) + + for item in msg.item_list: + if item.type == MessageItem.TEXT and item.text_item and item.text_item.text: + text = item.text_item.text + + # Handle quoted messages + if item.ref_msg: + ref_parts = [] + if item.ref_msg.title: + ref_parts.append(item.ref_msg.title) + if item.ref_msg.message_item: + ref_item = item.ref_msg.message_item + if ref_item.text_item and ref_item.text_item.text: + ref_parts.append(ref_item.text_item.text) + if ref_parts: + components.append( + platform_message.Quote( + sender_id='', + origin=platform_message.MessageChain( + [platform_message.Plain(text=' | '.join(ref_parts))] + ), + ) + ) + + components.append(platform_message.Plain(text=text)) + + elif item.type == MessageItem.IMAGE and item.image_item: + if hasattr(item.image_item, '_downloaded_bytes') and item.image_item._downloaded_bytes: + b64 = base64.b64encode(item.image_item._downloaded_bytes).decode('utf-8') + components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}')) + else: + components.append(platform_message.Unknown(text='[Image]')) + + elif item.type == MessageItem.VOICE and item.voice_item: + # Voice with speech-to-text: use the transcribed text + if item.voice_item.text: + components.append(platform_message.Plain(text=item.voice_item.text)) + else: + components.append(platform_message.Unknown(text='[Voice]')) + + # TODO: enable after full testing + # elif item.type == MessageItem.VOICE and item.voice_item: + # if item.voice_item.text: + # components.append(platform_message.Plain(text=item.voice_item.text)) + # elif hasattr(item.voice_item, '_downloaded_bytes') and item.voice_item._downloaded_bytes: + # b64 = base64.b64encode(item.voice_item._downloaded_bytes).decode('utf-8') + # components.append( + # platform_message.Voice( + # base64=b64, + # length=item.voice_item.playtime or 0, + # ) + # ) + # else: + # components.append( + # platform_message.Voice( + # length=item.voice_item.playtime or 0, + # ) + # ) + + elif item.type == MessageItem.FILE and item.file_item: + components.append(platform_message.Unknown(text=f'[File: {item.file_item.file_name or ""}]')) + + # TODO: enable after full testing + # elif item.type == MessageItem.FILE and item.file_item: + # file_name = item.file_item.file_name or '' + # file_size = int(item.file_item.len) if item.file_item.len else 0 + # if hasattr(item.file_item, '_downloaded_bytes') and item.file_item._downloaded_bytes: + # b64 = base64.b64encode(item.file_item._downloaded_bytes).decode('utf-8') + # components.append( + # platform_message.File( + # name=file_name, + # size=file_size, + # base64=b64, + # ) + # ) + # else: + # components.append( + # platform_message.File( + # name=file_name, + # size=file_size, + # ) + # ) + + elif item.type == MessageItem.VIDEO and item.video_item: + components.append(platform_message.Unknown(text='[Video]')) + + # TODO: enable after full testing + # elif item.type == MessageItem.VIDEO and item.video_item: + # if hasattr(item.video_item, '_downloaded_bytes') and item.video_item._downloaded_bytes: + # b64 = base64.b64encode(item.video_item._downloaded_bytes).decode('utf-8') + # components.append( + # platform_message.File( + # name='video.mp4', + # size=item.video_item.video_size or 0, + # base64=b64, + # ) + # ) + # else: + # components.append( + # platform_message.File( + # name='video.mp4', + # size=item.video_item.video_size or 0, + # ) + # ) + + else: + components.append(platform_message.Unknown(text='[Unknown message type]')) + + return platform_message.MessageChain(components) + + +class OpenClawWeixinEventConverter(abstract_platform_adapter.AbstractEventConverter): + """Converts OpenClaw WeChat messages to LangBot events.""" + + @staticmethod + async def yiri2target(event: platform_events.MessageEvent) -> dict: + return event.source_platform_object + + @staticmethod + async def target2yiri(msg: WeixinMessage) -> typing.Optional[platform_events.MessageEvent]: + """Convert an inbound WeixinMessage to a LangBot event.""" + if msg.message_type != WeixinMessage.TYPE_USER: + return None + + from_user_id = msg.from_user_id or '' + if not from_user_id: + return None + + message_chain = await OpenClawWeixinMessageConverter.target2yiri(msg) + if not message_chain: + return None + + timestamp = (msg.create_time_ms or 0) / 1000.0 + + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=from_user_id, + nickname=from_user_id, + remark='', + ), + message_chain=message_chain, + time=timestamp, + source_platform_object=msg, + ) + + +class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """LangBot adapter for OpenClaw WeChat (long-poll based).""" + + name: str = 'openclaw-weixin' + + client: OpenClawWeixinClient = pydantic.Field(exclude=True) + + config: dict + + message_converter: OpenClawWeixinMessageConverter = OpenClawWeixinMessageConverter() + event_converter: OpenClawWeixinEventConverter = OpenClawWeixinEventConverter() + + # context_token cache: from_user_id -> context_token + _context_tokens: dict[str, str] = pydantic.PrivateAttr(default_factory=dict) + + _polling: bool = pydantic.PrivateAttr(default=False) + _poll_task: typing.Optional[asyncio.Task] = pydantic.PrivateAttr(default=None) + _bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None) + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] = {} + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + client = OpenClawWeixinClient( + base_url=config.get('base_url', DEFAULT_BASE_URL), + token=config.get('token', ''), + ) + super().__init__( + config=config, + logger=logger, + client=client, + bot_account_id='', + listeners={}, + name='openclaw-weixin', + ) + + def set_bot_uuid(self, bot_uuid: str): + """Called by BotManager to provide the bot's UUID for config persistence.""" + self._bot_uuid = bot_uuid + + async def _persist_config(self) -> None: + """Persist current self.config to the database so token survives restart.""" + if not self._bot_uuid: + return + try: + ap = self.logger.ap + await ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_bot.Bot) + .where(persistence_bot.Bot.uuid == self._bot_uuid) + .values(adapter_config=self.config) + ) + except Exception as e: + await self.logger.warning(f'Failed to persist adapter config: {e}') + + async def _do_login(self) -> None: + """Run the QR code login flow via client.login() and update config.""" + adapter_logger = self.logger + + async def _on_qrcode(qr_base64: str, _qr_url: str): + await adapter_logger.info( + f'Please scan the QR code to login WeChat: {_qr_url}', + images=[platform_message.Image(base64=qr_base64)], + ) + + login_result = await self.client.login( + on_qrcode=_on_qrcode, + ) + + # client.login() already updates client.token and client.base_url + self.config['token'] = login_result.token + self.config['base_url'] = login_result.base_url + if login_result.account_id: + self.config['account_id'] = login_result.account_id + + await self.logger.info(f'WeChat login successful! account_id={login_result.account_id}') + + # Persist token to database so it survives restart + await self._persist_config() + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ): + """Send a message to a user.""" + context_token = self._context_tokens.get(target_id, '') + + for component in message: + try: + if isinstance(component, platform_message.Plain): + if component.text: + await self.client.send_text(target_id, component.text, context_token) + + elif isinstance(component, platform_message.Image): + img_bytes, _ = await component.get_bytes() + await self.client.send_image(target_id, img_bytes, context_token) + + elif isinstance(component, platform_message.File): + file_bytes = await self._get_component_bytes(component) + if file_bytes: + await self.client.send_file(target_id, file_bytes, component.name or 'file', context_token) + + elif isinstance(component, platform_message.Voice): + voice_bytes = await self._get_component_bytes(component) + if voice_bytes: + await self.client.send_voice(target_id, voice_bytes, component.length or 0, context_token) + + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + await self.send_message(target_type, target_id, node.message_chain) + + except Exception: + await self.logger.error( + f'Failed to send component {type(component).__name__}: {traceback.format_exc()}' + ) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + """Reply to a received message.""" + source_msg = message_source.source_platform_object + if isinstance(source_msg, WeixinMessage): + target_id = source_msg.from_user_id or '' + if target_id: + await self.send_message('friend', target_id, message) + + async def is_muted(self, group_id: int) -> bool: + return False + + @staticmethod + async def _get_component_bytes(component: platform_message.MessageComponent) -> typing.Optional[bytes]: + """Extract raw bytes from a File or Voice component.""" + b64_val = getattr(component, 'base64', None) + url_val = getattr(component, 'url', None) + path_val = getattr(component, 'path', None) + + if b64_val: + return base64.b64decode(b64_val) + elif url_val and url_val.startswith(('http://', 'https://')): + import aiohttp + + async with aiohttp.ClientSession() as session: + async with session.get(url_val) as resp: + if resp.status == 200: + return await resp.read() + elif path_val: + import asyncio + + with open(path_val, 'rb') as f: + return await asyncio.to_thread(f.read) + return None + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], + None, + ], + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], + None, + ], + ): + self.listeners.pop(event_type, None) + + async def run_async(self): + """Start the adapter. If no token is configured, trigger QR code login first.""" + base_url = self.config.get('base_url', DEFAULT_BASE_URL) + token = self.config.get('token', '') + + await self.logger.info('OpenClaw WeChat adapter starting...') + + # QR code login flow when no token is provided + if not token: + await self.logger.info('No token configured, starting QR code login...') + try: + await self._do_login() + except Exception as e: + await self.logger.error(f'QR code login failed: {e}') + raise + + # Rebuild client with the (possibly updated) config + self.client = OpenClawWeixinClient( + base_url=self.config.get('base_url', base_url), + token=self.config.get('token', token), + ) + self.bot_account_id = self.config.get('account_id', 'openclaw-weixin') + self._polling = True + + # Start the long-poll loop + self._poll_task = asyncio.create_task(self._poll_loop()) + await self.logger.info('OpenClaw WeChat adapter running') + + try: + await self._poll_task + except asyncio.CancelledError: + pass + + async def _poll_loop(self): + """Long-poll loop: call getUpdates continuously. + + Error handling follows the weixin-bot SDK pattern: + - Exponential backoff (1s -> 10s max) on failures + - Session expired (errcode -14) triggers automatic re-login + """ + get_updates_buf = '' + poll_timeout = float(self.config.get('poll_timeout', 35)) + + backoff_delay = 1.0 + max_backoff = 10.0 + + while self._polling: + try: + resp = await self.client.get_updates( + get_updates_buf=get_updates_buf, + timeout=poll_timeout + 5, + ) + + if resp.longpolling_timeout_ms and resp.longpolling_timeout_ms > 0: + poll_timeout = resp.longpolling_timeout_ms / 1000.0 + + is_api_error = (resp.ret is not None and resp.ret != 0) or ( + resp.errcode is not None and resp.errcode != 0 + ) + if is_api_error: + is_session_expired = resp.errcode == SESSION_EXPIRED_ERRCODE or resp.ret == SESSION_EXPIRED_ERRCODE + + if is_session_expired: + await self.logger.error('OpenClaw WeChat session expired, attempting re-login...') + try: + await self._do_login() + # Rebuild client with new credentials + self.client = OpenClawWeixinClient( + base_url=self.config.get('base_url', DEFAULT_BASE_URL), + token=self.config.get('token', ''), + ) + self._context_tokens.clear() + get_updates_buf = '' + backoff_delay = 1.0 + continue + except Exception: + await self.logger.error(f'Re-login failed: {traceback.format_exc()}') + break + + await self.logger.error( + f'OpenClaw getUpdates failed: ret={resp.ret} errcode={resp.errcode} errmsg={resp.errmsg}' + ) + await asyncio.sleep(backoff_delay) + backoff_delay = min(backoff_delay * 2, max_backoff) + continue + + backoff_delay = 1.0 + + if resp.get_updates_buf: + get_updates_buf = resp.get_updates_buf + + for msg in resp.msgs: + try: + await self._handle_inbound_message(msg) + except Exception: + await self.logger.error(f'Error handling message: {traceback.format_exc()}') + + except asyncio.CancelledError: + break + except Exception: + await self.logger.error(f'OpenClaw poll error: {traceback.format_exc()}') + await asyncio.sleep(backoff_delay) + backoff_delay = min(backoff_delay * 2, max_backoff) + + async def _handle_inbound_message(self, msg: WeixinMessage): + """Process a single inbound message from getUpdates.""" + if msg.context_token and msg.from_user_id: + self._context_tokens[msg.from_user_id] = msg.context_token + + # Download CDN media (files, images) before converting to LangBot events + await self._download_media_items(msg) + + event = await OpenClawWeixinEventConverter.target2yiri(msg) + if event is None: + return + + if type(event) in self.listeners: + await self.listeners[type(event)](event, self) + + async def _download_media_items(self, msg: WeixinMessage): + """Download CDN media for image items in the message.""" + if not msg.item_list: + return + + for item in msg.item_list: + try: + if item.type == MessageItem.IMAGE and item.image_item: + if ( + item.image_item.media + and item.image_item.media.encrypt_query_param + and item.image_item.media.aes_key + ): + img_bytes = await self.client.download_media(item.image_item.media) + item.image_item._downloaded_bytes = img_bytes + + # TODO: enable after full testing + # elif item.type == MessageItem.FILE and item.file_item and item.file_item.media: + # if item.file_item.media.encrypt_query_param and item.file_item.media.aes_key: + # file_bytes = await self.client.download_media(item.file_item.media) + # item.file_item._downloaded_bytes = file_bytes + # + # elif item.type == MessageItem.VOICE and item.voice_item and item.voice_item.media: + # if item.voice_item.media.encrypt_query_param and item.voice_item.media.aes_key: + # voice_bytes = await self.client.download_media(item.voice_item.media) + # item.voice_item._downloaded_bytes = voice_bytes + # + # elif item.type == MessageItem.VIDEO and item.video_item and item.video_item.media: + # if item.video_item.media.encrypt_query_param and item.video_item.media.aes_key: + # video_bytes = await self.client.download_media(item.video_item.media) + # item.video_item._downloaded_bytes = video_bytes + + except Exception: + await self.logger.warning(f'Failed to download CDN media: {traceback.format_exc()}') + + async def kill(self) -> bool: + """Stop the adapter.""" + self._polling = False + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + await self.client.close() + await self.logger.info('OpenClaw WeChat adapter stopped') + return True diff --git a/src/langbot/pkg/platform/sources/openclaw_weixin.yaml b/src/langbot/pkg/platform/sources/openclaw_weixin.yaml new file mode 100644 index 0000000..a8dec64 --- /dev/null +++ b/src/langbot/pkg/platform/sources/openclaw_weixin.yaml @@ -0,0 +1,88 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: openclaw-weixin + label: + en_US: OpenClaw WeChat + zh_Hans: 个人微信机器人 + zh_Hant: 個人微信機器人 + description: + en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login + zh_Hans: 微信官方个人助手,扫码即可登录使用 + zh_Hant: 微信官方個人助手,掃碼即可登入使用 + icon: wechat.png +spec: + categories: + - popular + - china + help_links: + zh: https://link.langbot.app/zh/platforms/openclaw_weixin + en: https://link.langbot.app/en/platforms/openclaw_weixin + ja: https://link.langbot.app/ja/platforms/openclaw_weixin + config: + - name: base_url + label: + en_US: API Base URL + zh_Hans: API 基础地址 + zh_Hant: API 基礎地址 + description: + en_US: The base URL of the OpenClaw WeChat backend API + zh_Hans: OpenClaw 微信后端 API 的基础地址 + zh_Hant: OpenClaw 微信後端 API 的基礎地址 + type: string + required: true + default: "https://ilinkai.weixin.qq.com" + - name: qr-login + label: + en_US: Scan QR Login + zh_Hans: 扫码登录 + zh_Hant: 掃碼登入 + ja_JP: QRコードでログイン + description: + en_US: Scan QR code with WeChat to authorize and automatically fill in the token + zh_Hans: 使用微信扫码授权,自动填写令牌 + zh_Hant: 使用微信掃碼授權,自動填寫令牌 + ja_JP: WeChatでQRコードをスキャンし、トークンを自動入力 + type: qr-code-login + login_platform: weixin + required: false + - name: token + label: + en_US: Token + zh_Hans: 令牌 + zh_Hant: 令牌 + description: + en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup. + zh_Hans: 扫码登录授权后获取的 Bearer 令牌。请留空并保存,将在启动时输出二维码到日志,扫码后即可自动登录。 + zh_Hant: 掃碼登入授權後取得的 Bearer 令牌。請留空並儲存,將在啟動時輸出 QR Code 到日誌,掃碼後即可自動登入。 + type: string + required: false + default: "" + - name: account_id + label: + en_US: Account ID + zh_Hans: 账号标识 + zh_Hant: 帳號標識 + description: + en_US: A label for this WeChat account (used for display purposes) + zh_Hans: 此微信账号的标识(用于显示) + zh_Hant: 此微信帳號的標識(用於顯示) + type: string + required: false + default: "openclaw-weixin" + - name: poll_timeout + label: + en_US: Poll Timeout (seconds) + zh_Hans: 轮询超时(秒) + zh_Hant: 輪詢逾時(秒) + description: + en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration + zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长 + zh_Hant: getUpdates 長輪詢逾時時間,伺服端最多持有請求的時長 + type: integer + required: false + default: 35 +execution: + python: + path: ./openclaw_weixin.py + attr: OpenClawWeixinAdapter diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py new file mode 100644 index 0000000..35fd81f --- /dev/null +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -0,0 +1,1138 @@ +from __future__ import annotations +import typing +import re +import asyncio +import traceback + +import datetime +import time + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +from langbot.libs.qq_official_api.api import ( + QQ_SELECT_ACTION_PREFIX, + QQOfficialClient, + build_keyboard_from_form, + build_keyboard_from_select_field, + resolve_select_button_action, +) +from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent +from ...utils import image +from ..logger import EventLogger + + +def _is_base64_data(value: str) -> bool: + """Check if a string contains base64-encoded data rather than a URL.""" + if not value: + return False + # data: URI scheme (e.g. data:image/png;base64,xxx) + if value.startswith('data:'): + return True + # Only treat as base64 if it doesn't look like a URL/path and has valid base64 chars + if value.startswith(('http://', 'https://', '/', './', '../')): + return False + # Check if it looks like base64 (only valid chars, reasonable length) + return bool(re.fullmatch(r'[A-Za-z0-9+/=\s]{20,}', value)) + + +class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain): + """将 LangBot 消息链转换为 QQ Official 消息格式列表。""" + content_list = [] + for msg in message_chain: + if type(msg) is platform_message.Plain: + content_list.append( + { + 'type': 'text', + 'content': msg.text, + } + ) + elif type(msg) is platform_message.Image: + url = msg.url if hasattr(msg, 'url') and msg.url else None + b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None + # Some plugins (e.g. MimoTTS) store base64 data in the url field + if url and not b64 and _is_base64_data(url): + b64 = url + url = None + content_list.append( + { + 'type': 'image', + 'url': url, + 'base64': b64, + } + ) + elif type(msg) is platform_message.Voice: + url = msg.url if hasattr(msg, 'url') and msg.url else None + b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None + # Some plugins (e.g. MimoTTS) store base64 data in the url field + if url and not b64 and _is_base64_data(url): + b64 = url + url = None + content_list.append( + { + 'type': 'voice', + 'url': url, + 'base64': b64, + } + ) + elif type(msg) is platform_message.File: + url = msg.url if hasattr(msg, 'url') and msg.url else None + b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None + # Some plugins store base64 data in the url field + if url and not b64 and _is_base64_data(url): + b64 = url + url = None + content_list.append( + { + 'type': 'file', + 'url': url, + 'base64': b64, + 'name': msg.name if hasattr(msg, 'name') else 'file', + } + ) + + return content_list + + @staticmethod + async def target2yiri(message: str, message_id: str, pic_url: str, content_type): + yiri_msg_list = [] + yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now())) + if pic_url is not None: + base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type) + yiri_msg_list.append(platform_message.Image(base64=base64_url)) + + yiri_msg_list.append(platform_message.Plain(text=message)) + chain = platform_message.MessageChain(yiri_msg_list) + return chain + + +class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.MessageEvent) -> QQOfficialEvent: + return event.source_platform_object + + @staticmethod + async def target2yiri(event: QQOfficialEvent): + """ + QQ官方消息转换为LB对象 + """ + yiri_chain = await QQOfficialMessageConverter.target2yiri( + message=event.content, + message_id=event.d_id, + pic_url=event.attachments, + content_type=event.content_type, + ) + + if event.t == 'C2C_MESSAGE_CREATE': + friend = platform_entities.Friend( + id=event.user_openid, + nickname=event.t, + remark='', + ) + return platform_events.FriendMessage( + sender=friend, + message_chain=yiri_chain, + time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), + source_platform_object=event, + ) + + if event.t == 'DIRECT_MESSAGE_CREATE': + friend = platform_entities.Friend( + id=event.guild_id, + nickname=event.t, + remark='', + ) + return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, source_platform_object=event) + if event.t == 'GROUP_AT_MESSAGE_CREATE': + yiri_chain.insert(0, platform_message.At(target='justbot')) + + sender = platform_entities.GroupMember( + id=event.group_openid, + member_name=event.t, + permission='MEMBER', + group=platform_entities.Group( + id=event.group_openid, + name='MEMBER', + permission=platform_entities.Permission.Member, + ), + special_title='', + ) + time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()) + return platform_events.GroupMessage( + sender=sender, + message_chain=yiri_chain, + time=time, + source_platform_object=event, + ) + if event.t == 'AT_MESSAGE_CREATE': + yiri_chain.insert(0, platform_message.At(target='justbot')) + sender = platform_entities.GroupMember( + id=event.channel_id, + member_name=event.t, + permission='MEMBER', + group=platform_entities.Group( + id=event.channel_id, + name='MEMBER', + permission=platform_entities.Permission.Member, + ), + special_title='', + ) + time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()) + return platform_events.GroupMessage( + sender=sender, + message_chain=yiri_chain, + time=time, + source_platform_object=event, + ) + + +class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + bot: QQOfficialClient + config: dict + bot_account_id: str + bot_uuid: str = None + enable_webhook: bool = False + message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter() + event_converter: QQOfficialEventConverter = QQOfficialEventConverter() + ap: typing.Any = None + + def __init__(self, config: dict, logger: EventLogger): + enable_webhook = config.get('enable-webhook', False) + + bot = QQOfficialClient( + app_id=config['appid'], + secret=config['secret'], + token=config['token'], + logger=logger, + unified_mode=enable_webhook, + ) + + super().__init__( + config=config, + logger=logger, + bot=bot, + bot_account_id=config['appid'], + ) + + self.enable_webhook = enable_webhook + self._ws_task: asyncio.Task = None + self._stream_ctx: dict = {} + self._stream_ctx_ts: dict[str, float] = {} + self._fallback_text: dict[str, str] = {} + self._fallback_text_ts: dict[str, float] = {} + # Dify form-action bookkeeping for the human-input button flow. + # session_key = "_" where scene is c2c/group/channel and + # id is user_openid / group_openid / channel_id. + # session_key -> {form_data, msg_id, event_id, scene, target_id, + # sender_id, posted_at} + # Set when we send a markdown+keyboard card and consulted when: + # (a) INTERACTION_CREATE fires — we look up the form by + # session_key (button's `data` carries the action_id), + # (b) the resumed-workflow query needs to find a passive-reply + # event_id (INTERACTION_CREATE id, 30-min validity). + self._pending_forms: dict[str, dict] = {} + # session_key -> most recent ``INTERACTION_CREATE`` event_id, used + # as the passive event_id for the resumed query's LLM output. + self._session_event_ids: dict[str, dict] = {} + # Per-anchor msg_seq counter. QQ accepts up to 5 passive replies + # per (msg_id|event_id) within 60 min, but each reuse needs a + # fresh ``msg_seq`` — re-sending with msg_seq=1 is silently dedup'd. + self._anchor_msg_seq: dict[str, int] = {} + + # Wire button-click handler so webhook mode catches INTERACTION_CREATE. + # (ws mode is wired separately via on_event in _run_websocket so the + # raw payload bypasses get_message's message-only flattening.) + @self.bot.on_interaction() + async def _on_interaction(event_data: dict, interaction_id: typing.Optional[str]): + await self._handle_interaction_create(event_data, interaction_id) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + qq_official_event = await QQOfficialEventConverter.yiri2target( + message_source, + ) + + # Synthetic event (button-click resume): no inbound platform + # object → no msg_id. Route via the cached INTERACTION_CREATE + # event_id (valid 30 min, no quota cost). + if qq_official_event is None: + await self._reply_synthetic(message_source, message) + return + + content_list = await QQOfficialMessageConverter.yiri2target(message) + + # 确定 target_type 和 target_id + target_type = None + target_id = None + + if qq_official_event.t == 'C2C_MESSAGE_CREATE': + target_type = 'c2c' + target_id = qq_official_event.user_openid + elif qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE': + target_type = 'group' + target_id = qq_official_event.group_openid + elif qq_official_event.t == 'AT_MESSAGE_CREATE': + # 频道群聊使用频道 API,暂不支持富媒体 + for content in content_list: + if content['type'] == 'text': + await self.bot.send_channle_group_text_msg( + qq_official_event.channel_id, + content['content'], + qq_official_event.d_id, + ) + return + elif qq_official_event.t == 'DIRECT_MESSAGE_CREATE': + # 频道私聊使用频道 API,暂不支持富媒体 + for content in content_list: + if content['type'] == 'text': + await self.bot.send_channle_private_text_msg( + qq_official_event.guild_id, + content['content'], + qq_official_event.d_id, + ) + return + + # C2C 和群聊:支持文字 + 富媒体 + for content in content_list: + content_type = content.get('type', 'text') + + if content_type == 'text': + if target_type == 'c2c': + await self.bot.send_private_text_msg( + target_id, + content['content'], + qq_official_event.d_id, + ) + elif target_type == 'group': + await self.bot.send_group_text_msg( + target_id, + content['content'], + qq_official_event.d_id, + ) + + elif content_type == 'image': + file_url = content.get('url') + file_data = content.get('base64') + if file_url or file_data: + await self.bot.send_image_msg( + target_type, + target_id, + file_url=file_url, + file_data=file_data, + msg_id=qq_official_event.d_id, + ) + + elif content_type == 'voice': + file_url = content.get('url') + file_data = content.get('base64') + if file_url or file_data: + await self.bot.send_voice_msg( + target_type, + target_id, + file_url=file_url, + file_data=file_data, + msg_id=qq_official_event.d_id, + ) + + elif content_type == 'file': + file_url = content.get('url') + file_data = content.get('base64') + file_name = content.get('name', 'file') + if file_url or file_data: + await self.bot.send_file_msg( + target_type, + target_id, + file_url=file_url, + file_data=file_data, + file_name=file_name, + msg_id=qq_official_event.d_id, + ) + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + pass + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + async def on_message(event: QQOfficialEvent): + self.bot_account_id = 'justbot' + try: + return await callback(await self.event_converter.target2yiri(event), self) + except Exception: + await self.logger.error(f'Error in qqofficial callback: {traceback.format_exc()}') + + if event_type == platform_events.FriendMessage: + self.bot.on_message('DIRECT_MESSAGE_CREATE')(on_message) + self.bot.on_message('C2C_MESSAGE_CREATE')(on_message) + elif event_type == platform_events.GroupMessage: + self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message) + self.bot.on_message('AT_MESSAGE_CREATE')(on_message) + + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + """处理统一 webhook 请求。 + + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) + request: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self.bot.handle_unified_webhook(request) + + async def run_async(self): + if not self.enable_webhook: + await self._run_websocket() + else: + # 统一 webhook 模式下,不启动独立的 Quart 应用 + async def keep_alive(): + while True: + await asyncio.sleep(1) + + await keep_alive() + + async def _run_websocket(self): + """以 WebSocket 模式运行网关连接""" + await self.logger.info('QQ Official adapter starting in WebSocket mode') + + async def on_ready(): + await self.logger.info('QQ Official WebSocket connected and ready') + + async def on_event(event_type: str, event_data: dict): + # INTERACTION_CREATE is dispatched via bot.on_interaction() + # (registered in __init__) so we get the top-level ws_event_id + # — needed as the passive-reply event_id. It never reaches here. + # 只处理消息事件,忽略 READY/RESUMED 等系统事件 + message_event_types = { + 'C2C_MESSAGE_CREATE', + 'DIRECT_MESSAGE_CREATE', + 'GROUP_AT_MESSAGE_CREATE', + 'AT_MESSAGE_CREATE', + } + if event_type not in message_event_types: + return + if not isinstance(event_data, dict): + await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}') + return + await self.logger.info(f'Processing message event: {event_type}') + # 构造与 webhook 模式相同的 payload 结构 + payload = {'t': event_type, 'd': event_data} + message_data = await self.bot.get_message(payload) + if message_data: + event = QQOfficialEvent.from_payload(message_data) + await self.bot._handle_message(event) + + async def on_error(error: Exception): + await self.logger.error(f'WebSocket error: {error}') + await self.logger.error(f'QQ Official WebSocket error: {error}') + + self._ws_task = asyncio.create_task(self.bot.connect_gateway_loop(on_event, on_ready, on_error)) + try: + await self._ws_task + except asyncio.CancelledError: + pass + + async def kill(self) -> bool: + if self._ws_task: + self._ws_task.cancel() + try: + await self._ws_task + except asyncio.CancelledError: + pass + self._ws_task = None + return True + + # --------------- 流式输出 --------------- + + _STREAM_CTX_TTL = 300 # seconds + + async def _cleanup_stale_streams(self): + """Remove stream contexts that have not been updated for more than _STREAM_CTX_TTL seconds.""" + now = time.time() + stale_ids = [mid for mid, ts in self._stream_ctx_ts.items() if now - ts > self._STREAM_CTX_TTL] + for mid in stale_ids: + self._stream_ctx.pop(mid, None) + self._stream_ctx_ts.pop(mid, None) + stale_fb = [mid for mid, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL] + for mid in stale_fb: + self._fallback_text.pop(mid, None) + self._fallback_text_ts.pop(mid, None) + if stale_ids or stale_fb: + await self.logger.debug(f'Cleaned up {len(stale_ids)} stream contexts, {len(stale_fb)} fallback texts') + + async def is_stream_output_supported(self) -> bool: + return self.config.get('enable-stream-reply', False) + + @staticmethod + def _is_form_placeholder_chunk(text: str) -> bool: + """Return True for invisible placeholder chunks used to carry forms.""" + + if not text: + return False + + cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip() + # Some Windows consoles/logs display the zero-width placeholder as + # mojibake. Treat those variants as the same non-user-facing marker. + return cleaned in {'', '鈥?', '​'} + + async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool: + source = event.source_platform_object + # Synthetic events (button-click resume) have no source object — + # they ride a cached INTERACTION_CREATE event_id, not a streamable + # msg_id. Skip stream setup; reply_message handles the one-shot + # send at is_final. + if source is None: + return False + # Streaming API only supports C2C private chat + if source.t != 'C2C_MESSAGE_CREATE': + return False + + # The stream endpoint still consumes msg_seq for this inbound msg_id. + # Keep the passive-reply counter in sync so a follow-up form card uses + # msg_seq=2 instead of being deduplicated by QQ as another seq=1 send. + if source.d_id: + self._anchor_msg_seq[source.d_id] = max(self._anchor_msg_seq.get(source.d_id, 0), 1) + + ctx = { + 'user_openid': source.user_openid, + 'msg_id': source.d_id, + 'stream_msg_id': None, + 'msg_seq': 1, + 'index': 0, + 'last_update_ts': 0, + 'accumulated_text': '', + 'sent_length': 0, + 'session_started': False, + } + + self._stream_ctx[message_id] = ctx + self._stream_ctx_ts[message_id] = time.time() + return True + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message: dict, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + # Periodically clean up stale stream contexts + await self._cleanup_stale_streams() + + # Dify human-input pause: when the runner attaches `_form_data` to + # the final chunk, finalize any in-flight stream session and send + # a markdown + keyboard message instead. Plain-text content from + # earlier chunks is already on the stream; we close it cleanly + # and the buttons land as a separate reply. + form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None + if is_final: + _resume = getattr(bot_message, '_resume_from_form', None) if not isinstance(bot_message, dict) else None + _open_new = getattr(bot_message, '_open_new_card', None) if not isinstance(bot_message, dict) else None + if self.ap is not None: + self.ap.logger.info( + f'QQ Official reply_message_chunk final: ' + f'type={type(bot_message).__name__} ' + f'is_final={is_final} ' + f'form_data_present={form_data is not None} ' + f'resume_from_form={_resume} open_new_card={_open_new} ' + f'content_len={len(getattr(bot_message, "content", "") or "")}' + ) + if form_data and is_final: + await self._handle_form_chunk(message_source, message, form_data) + return + + # 提取纯文本内容(当前 chunk 的文本) + text_parts = [] + for msg in message: + if type(msg) is platform_message.Plain: + text_parts.append(msg.text) + chunk_text = '\n\n'.join(text_parts) + if self._is_form_placeholder_chunk(chunk_text): + await self.logger.debug('QQ Official: skipped invisible form placeholder chunk') + return + + message_id = ( + bot_message.get('resp_message_id') + if isinstance(bot_message, dict) + else getattr(bot_message, 'resp_message_id', None) + ) + if not message_id or message_id not in self._stream_ctx: + # 非流式场景(如群聊不支持流式),累积文本后一次性回复 + if chunk_text: + # Chunks carry the latest full snapshot, not a text delta. + self._fallback_text[message_id] = chunk_text + self._fallback_text_ts[message_id] = time.time() + if is_final: + full_text = self._fallback_text.pop(message_id, '') + if full_text: + fallback_msg = platform_message.MessageChain([platform_message.Plain(text=full_text)]) + await self.reply_message(message_source, fallback_msg, quote_origin) + return + + ctx = self._stream_ctx[message_id] + + # 累积文本 + if chunk_text: + ctx['accumulated_text'] = chunk_text + + # 未启动会话时,等第一个有内容的 chunk 来建立会话 + if not ctx['session_started']: + if not ctx['accumulated_text']: + return + # 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀) + ctx['session_started'] = True + + # 发送内容 = 全量累积文本 + # QQ API 的 replace 模式不允许修改已下发前缀,所以: + # - 首次:发送全部文本,建立会话 + # - 后续:只能发送新增部分(append 行为) + content_to_send = ctx['accumulated_text'][ctx['sent_length'] :] + if not content_to_send and not is_final: + return + + input_state = 10 if is_final else 1 + + # Rate limiting: skip non-final updates if last update was <0.5s ago + now = time.time() + if not is_final and (now - ctx['last_update_ts']) < 0.5: + return + ctx['last_update_ts'] = now + + try: + resp = await self.bot.send_stream_msg( + user_openid=ctx['user_openid'], + content=content_to_send, + event_id=ctx['msg_id'], + msg_id=ctx['msg_id'], + msg_seq=ctx['msg_seq'], + index=ctx['index'], + stream_msg_id=ctx['stream_msg_id'], + input_state=input_state, + ) + if resp and isinstance(resp, dict): + new_stream_id = resp.get('id') + if new_stream_id: + ctx['stream_msg_id'] = new_stream_id + ctx['sent_length'] = len(ctx['accumulated_text']) + ctx['index'] += 1 + await self.logger.debug( + f'[QQ Official] 流式 chunk 已发送, index={ctx["index"]}, ' + f'sent_len={ctx["sent_length"]}, is_final={is_final}' + ) + except Exception as e: + await self.logger.error(f'Failed to send stream message: {e}') + + if is_final: + self._stream_ctx.pop(message_id, None) + + def unregister_listener( + self, + event_type: type, + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + return super().unregister_listener(event_type, callback) + + # ------------------------------------------------------------------ + # Dify human-input button-interaction support + # ------------------------------------------------------------------ + + _PENDING_FORM_TTL = 1800 # 30 min — matches QQ passive-reply window. + _MAX_REPLIES_PER_ANCHOR = 5 # QQ hard limit per msg_id / event_id. + + def _next_msg_seq(self, anchor: str) -> typing.Optional[int]: + """Return the next msg_seq for an anchor, or ``None`` if the + anchor has already been used 5 times (further sends would be + silently dropped by QQ).""" + if not anchor: + return 1 + used = self._anchor_msg_seq.get(anchor, 0) + if used >= self._MAX_REPLIES_PER_ANCHOR: + return None + self._anchor_msg_seq[anchor] = used + 1 + return used + 1 + + async def _reply_synthetic( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + ) -> None: + """Deliver a reply for a synthetic (button-click-resume) event. + + Synthetic events have ``source_platform_object=None`` and no + fresh inbound msg_id. The previous INTERACTION_CREATE id we + cached in :attr:`_session_event_ids` is a valid passive-reply + anchor (``event_id``) for up to 30 minutes — use it. + """ + if isinstance(message_source, platform_events.GroupMessage): + target_type = 'group' + group = getattr(message_source, 'group', None) or ( + message_source.sender.group if hasattr(message_source.sender, 'group') else None + ) + target_id = str(group.id) if group else None + else: + target_type = 'c2c' + target_id = str(message_source.sender.id) if message_source.sender else None + + if not target_id: + await self.logger.warning('QQ Official: synthetic reply has no target_id; dropping') + return + + session_key = f'{target_type}_{target_id}' + cached = self._session_event_ids.get(session_key) + event_id = cached.get('event_id') if cached else None + if cached and (time.time() - cached.get('posted_at', 0)) > self._PENDING_FORM_TTL: + event_id = None + + if not event_id: + await self.logger.warning( + f'QQ Official: no cached event_id for {session_key}; ' + f'cannot deliver synthetic reply within passive-reply window' + ) + return + + content_list = await QQOfficialMessageConverter.yiri2target(message) + text_parts = [c['content'] for c in content_list if c.get('type') == 'text' and c.get('content')] + if not text_parts: + await self.logger.info('QQ Official: synthetic reply has no text content; skipping') + return + text = '\n\n'.join(text_parts) + + msg_seq = self._next_msg_seq(event_id) + if msg_seq is None: + await self.logger.warning( + f'QQ Official: anchor {event_id!r} exhausted (>5 passive replies); ' + f'cannot deliver synthetic reply for {session_key}' + ) + return + + try: + if target_type == 'c2c': + await self.bot.send_private_text_msg( + user_openid=target_id, + content=text, + event_id=event_id, + msg_seq=msg_seq, + ) + elif target_type == 'group': + await self.bot.send_group_text_msg( + group_openid=target_id, + content=text, + event_id=event_id, + msg_seq=msg_seq, + ) + except Exception: + await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}') + + def _resolve_target_from_source(self, source: QQOfficialEvent) -> typing.Optional[tuple[str, str]]: + """Return ``(target_type, target_id)`` for sending a reply, or + ``None`` if the scene cannot host a markdown+keyboard message.""" + if source is None: + return None + if source.t == 'C2C_MESSAGE_CREATE': + return 'c2c', source.user_openid + if source.t == 'GROUP_AT_MESSAGE_CREATE': + return 'group', source.group_openid + if source.t == 'AT_MESSAGE_CREATE': + return 'channel', source.channel_id + # DIRECT_MESSAGE_CREATE uses the guild DM API which does not accept + # markdown+keyboard at the time of writing — caller falls back to text. + return None + + def _resolve_target_from_event( + self, message_source: platform_events.MessageEvent + ) -> typing.Optional[tuple[str, str]]: + """Resolve ``(target_type, target_id)`` from the public event. + + Prefers the platform-native source when present; falls back to + the synthesized event's sender/group fields so button-click + resume queries can still find a destination. + """ + source = message_source.source_platform_object + if source is not None: + return self._resolve_target_from_source(source) + if isinstance(message_source, platform_events.GroupMessage): + group = getattr(message_source, 'group', None) or ( + message_source.sender.group + if message_source.sender and hasattr(message_source.sender, 'group') + else None + ) + if group and getattr(group, 'id', None): + return 'group', str(group.id) + if isinstance(message_source, platform_events.FriendMessage): + if message_source.sender and getattr(message_source.sender, 'id', None): + return 'c2c', str(message_source.sender.id) + return None + + def _prune_pending_forms(self) -> None: + now = time.time() + stale = [k for k, v in self._pending_forms.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL] + for k in stale: + self._pending_forms.pop(k, None) + stale_e = [ + k for k, v in self._session_event_ids.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL + ] + for k in stale_e: + self._session_event_ids.pop(k, None) + + async def _handle_form_chunk( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + form_data: dict, + ) -> None: + """Send the markdown + keyboard form prompt for a Dify pause. + + Called from ``reply_message_chunk`` when the runner attaches + ``_form_data`` to the final chunk. Replaces what would otherwise + be a plain-text numbered-list fallback. + """ + if self.ap is not None: + self.ap.logger.info( + f'QQ Official _handle_form_chunk entered; ' + f'source_present={message_source.source_platform_object is not None} ' + f'form_actions={len(form_data.get("actions") or [])}' + ) + self._prune_pending_forms() + + source = message_source.source_platform_object + scene_target = self._resolve_target_from_event(message_source) + if scene_target is None: + # No rich-UI fit — fall through to existing text path. + await self.logger.info('QQ Official: form chunk on unsupported scene; falling back to text') + text_parts = [m.text for m in message if type(m) is platform_message.Plain] + fallback_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(text_parts))]) + try: + await self.reply_message(message_source, fallback_msg) + except Exception: + await self.logger.error(f'QQ Official: form fallback text send failed: {traceback.format_exc()}') + return + + target_type, target_id = scene_target + session_key = f'{target_type}_{target_id}' + + # Cancel any in-flight stream / fallback ctx so plain-text prefix + # doesn't continue alongside the keyboard message. + msg_id = getattr(source, 'd_id', '') or '' if source is not None else '' + if msg_id: + self._stream_ctx.pop(msg_id, None) + self._stream_ctx_ts.pop(msg_id, None) + self._fallback_text.pop(msg_id, None) + self._fallback_text_ts.pop(msg_id, None) + + node_title = form_data.get('node_title') or 'Confirmation needed' + form_content = form_data.get('form_content') or '' + is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') + parts = [f'### {node_title}'] + plain_parts = [node_title] + if form_content.strip(): + parts.append(form_content.strip()) + plain_parts.append(form_content.strip()) + markdown_content = '\n\n'.join(parts) + plain_content = '\n\n'.join(plain_parts) + + keyboard = build_keyboard_from_select_field(form_data) if is_field_step else None + is_text_field_step = is_field_step and not keyboard.get('content', {}).get('rows') + if is_text_field_step: + keyboard = None + if keyboard is None and not is_text_field_step: + keyboard = build_keyboard_from_form(form_data, buttons_per_row=2) + if keyboard is not None and not keyboard.get('content', {}).get('rows') and not is_text_field_step: + # No actions to render — fall back to plain text. + text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)]) + try: + await self.reply_message(message_source, text_msg) + except Exception: + await self.logger.error(f'QQ Official: empty-keyboard fallback send failed: {traceback.format_exc()}') + return + + # Prefer the inbound msg_id (no quota cost). If the source is a + # synthetic event from a prior click, the cached interaction id + # serves as event_id for up to 30 min. + event_id = None + if not msg_id: + cached = self._session_event_ids.get(session_key) + if cached and (time.time() - cached.get('posted_at', 0)) < self._PENDING_FORM_TTL: + event_id = cached.get('event_id') + + anchor = msg_id or event_id or '' + msg_seq = self._next_msg_seq(anchor) + if msg_seq is None: + await self.logger.warning( + f'QQ Official: anchor {anchor!r} exhausted (>5 passive replies); ' + f'cannot deliver form card for session={session_key}' + ) + return + + try: + await self.bot.send_markdown_keyboard( + target_type=target_type, + target_id=target_id, + markdown_content=markdown_content, + keyboard=keyboard, + msg_id=msg_id if (msg_id and not event_id) else None, + event_id=event_id, + msg_seq=msg_seq, + ) + if self.ap is not None: + self.ap.logger.info( + f'QQ Official: form card sent ' + f'target={target_type}/{target_id} ' + f'msg_id={msg_id!r} event_id={event_id!r} msg_seq={msg_seq}' + ) + except Exception: + if self.ap is not None: + self.ap.logger.error( + f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}' + ) + await self.logger.error( + f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}' + ) + text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)]) + try: + await self.reply_message(message_source, text_msg) + except Exception: + pass + return + + sender_id = '' + if source is not None: + sender_id = ( + getattr(source, 'user_openid', None) + or getattr(source, 'member_openid', None) + or getattr(source, 'd_author_id', None) + or '' + ) + if not sender_id and message_source.sender is not None: + sender_id = str(getattr(message_source.sender, 'id', '') or '') + self._pending_forms[session_key] = { + 'form_data': form_data, + 'msg_id': msg_id, + 'sender_id': sender_id, + 'target_type': target_type, + 'target_id': target_id, + 'source_event_t': source.t if source is not None else None, + 'posted_at': time.time(), + } + await self.logger.info( + f'QQ Official: form posted session={session_key} actions={len(form_data.get("actions") or [])}' + ) + + async def _handle_interaction_create( + self, + event_data: dict, + ws_event_id: typing.Optional[str] = None, + ) -> None: + """Handle a button-click INTERACTION_CREATE event. + + Two IDs at play (QQ keeps them separate): + ws_event_id top-level payload ``id`` (or webhook ``X-Bot- + Event-Id``). The ONLY value accepted as + ``event_id`` for subsequent passive replies. + d['id'] the interaction id — used for PUT + /interactions/{id} ack. Cannot be reused as + event_id (QQ returns 40034025 if you try). + + Layout (https://bot.q.qq.com/.../msg-btn.html): + chat_type 0 channel / 1 group / 2 c2c + data.resolved.button_data what we set as ``action.data`` + data.resolved.button_id ``id`` field on the button row + """ + import langbot_plugin.api.entities.builtin.provider.session as provider_session + + if self.ap is not None: + self.ap.logger.info( + f'QQ Official _handle_interaction_create entered; ' + f'ws_event_id={ws_event_id!r} ' + f'interaction_id={(event_data.get("id") if isinstance(event_data, dict) else None)!r} ' + f'chat_type={event_data.get("chat_type") if isinstance(event_data, dict) else None}' + ) + + if not isinstance(event_data, dict): + await self.logger.warning(f'QQ Official: INTERACTION_CREATE event_data is not dict: {type(event_data)}') + return + + # ACK uses the interaction id, NOT the ws event id. + interaction_id = event_data.get('id') or '' + if interaction_id: + asyncio.create_task(self.bot.ack_interaction(interaction_id, code=0)) + + resolved = (event_data.get('data') or {}).get('resolved') or {} + action_id = str(resolved.get('button_data') or resolved.get('button_id') or '').strip() + if not action_id: + await self.logger.warning('QQ Official: INTERACTION_CREATE missing button_data/button_id; ignoring') + return + + chat_type = event_data.get('chat_type') + scene_target: typing.Optional[tuple[str, str]] = None + if chat_type == 2 or event_data.get('user_openid'): + scene_target = ('c2c', event_data.get('user_openid') or '') + elif chat_type == 1 or event_data.get('group_openid'): + scene_target = ('group', event_data.get('group_openid') or '') + elif chat_type == 0 or event_data.get('channel_id'): + scene_target = ('channel', event_data.get('channel_id') or '') + + if not scene_target or not scene_target[1]: + await self.logger.warning(f'QQ Official: INTERACTION_CREATE missing scene/target; raw={event_data}') + return + + target_type, target_id = scene_target + session_key = f'{target_type}_{target_id}' + + self._prune_pending_forms() + pending = self._pending_forms.get(session_key) + if not pending: + await self.logger.warning( + f'QQ Official: no pending form for session {session_key}; click ignored (action_id={action_id!r})' + ) + return + + # Cache ws_event_id so a follow-up pause / text reply can use it + # as event_id for passive delivery (30-min window). Falls back to + # the interaction_id only if no ws_event_id was provided (e.g. + # tests / older payload shape) — QQ will reject that value but + # we log so the mismatch is debuggable. + cached_event_id = ws_event_id or interaction_id + if cached_event_id: + self._session_event_ids[session_key] = { + 'event_id': cached_event_id, + 'posted_at': time.time(), + } + # New anchor → fresh 5-reply budget. + self._anchor_msg_seq[cached_event_id] = 0 + if self.ap is not None and not ws_event_id: + self.ap.logger.warning( + 'QQ Official: INTERACTION_CREATE lacked ws_event_id; ' + 'falling back to interaction_id (passive reply may be rejected)' + ) + + form_data: dict = pending.get('form_data') or {} + actions = form_data.get('actions') or [] + select_choice = resolve_select_button_action(form_data, action_id) + if action_id.startswith(QQ_SELECT_ACTION_PREFIX) and select_choice is None: + await self.logger.warning(f'QQ Official: invalid select action_id={action_id!r} for {session_key}') + return + + matched = None + if select_choice is None: + matched = next( + (a for a in actions if str(a.get('id', '')) == action_id), + None, + ) + if matched is None: + await self.logger.warning( + f'QQ Official: action_id={action_id!r} is not present on pending form for {session_key}' + ) + return + self._pending_forms.pop(session_key, None) + action_title = select_choice[1] if select_choice else matched.get('title') or action_id + + initiator_id = str(pending.get('sender_id') or '') + actor_id = str(event_data.get('member_openid') or event_data.get('user_openid') or initiator_id) + + # Build resume payload matching the shape every other adapter uses + # (DingTalk / Lark / Telegram / WeCom). The runner's + # _merge_pending_form_action consumes this verbatim. + if target_type == 'group' or target_type == 'channel': + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = target_id + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = target_id + + form_action_data = { + 'form_token': form_data.get('form_token', ''), + 'workflow_run_id': form_data.get('workflow_run_id', ''), + 'action_id': '' if select_choice else action_id, + 'action_title': action_title, + 'node_title': form_data.get('node_title', ''), + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': {'select': select_choice[1]} if select_choice else {}, + } + if select_choice: + form_action_data['_current_input_field'] = select_choice[0] + form_action_data['_input_progress'] = True + + event_label = 'Form Select' if select_choice else 'Form Action' + message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[{event_label}: {action_title}]')]) + + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=actor_id or launcher_id, + member_name='', + permission='MEMBER', + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=int(time.time()), + source_platform_object=None, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=actor_id or launcher_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=int(time.time()), + source_platform_object=None, + ) + + if self.ap is None: + await self.logger.error('QQ Official: ap not injected; cannot enqueue button-click query') + return + + bot_uuid = '' + pipeline_uuid = form_data.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + try: + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=actor_id or launcher_id, + message_event=synthetic_event, + message_chain=message_chain, + adapter=self, + pipeline_uuid=pipeline_uuid, + variables={ + '_dify_form_action': form_action_data, + '_routed_by_rule': True, + }, + ) + await self.logger.info( + f'QQ Official: button-click query enqueued action_id={action_id!r} ' + f'session={session_key} actor_id={actor_id}' + ) + except Exception: + await self.logger.error(f'QQ Official: enqueue button-click query failed: {traceback.format_exc()}') diff --git a/src/langbot/pkg/platform/sources/qqofficial.svg b/src/langbot/pkg/platform/sources/qqofficial.svg new file mode 100644 index 0000000..d0a07bc --- /dev/null +++ b/src/langbot/pkg/platform/sources/qqofficial.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml new file mode 100644 index 0000000..c3f193b --- /dev/null +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -0,0 +1,117 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: qqofficial + label: + en_US: QQ Official API + zh_Hans: QQ 官方 API + zh_Hant: QQ 官方 API + description: + en_US: QQ Official API (Webhook / WebSocket) + zh_Hans: QQ 官方 API,支持 Webhook 和 WebSocket 两种连接模式 + zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式 + icon: qqofficial.svg +spec: + categories: + - china + help_links: + zh: https://link.langbot.app/zh/platforms/qqofficial + en: https://link.langbot.app/en/platforms/qqofficial + ja: https://link.langbot.app/ja/platforms/qqofficial + config: + - name: __system.outbound_ips + label: + en_US: IP Whitelist + zh_Hans: IP 白名单 + zh_Hant: IP 白名單 + description: + en_US: Add these outbound IPs of the LangBot server to the IP whitelist in the development settings of the QQ Open Platform + zh_Hans: 请将这些 LangBot 服务器的出网 IP 添加到 QQ 开放平台开发设置中的 IP 白名单 + zh_Hant: 請將這些 LangBot 伺服器的出網 IP 加入 QQ 開放平台開發設定中的 IP 白名單 + type: array[string] + required: false + default: [] + - name: one-click-bind + label: + en_US: One-Click QR Binding + zh_Hans: 一键扫码绑定 + zh_Hant: 一鍵掃碼綁定 + description: + en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token is not used and can be left blank) + zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(当前未使用 Token,可留空) + zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(目前未使用 Token,可留空) + type: qr-code-login + login_platform: qqofficial + required: false + - name: appid + label: + en_US: App ID + zh_Hans: 应用ID + zh_Hant: 應用ID + type: string + required: true + default: "" + - name: secret + label: + en_US: Secret + zh_Hans: 密钥 + zh_Hant: 密鑰 + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_Hans: 令牌 + zh_Hant: 令牌 + description: + en_US: Optional. The QR binding cannot return this value; the current adapter implementation does not use it either, so it can be safely left blank. + zh_Hans: 可选。扫码绑定无法获取该字段,当前适配器实现也未使用该字段,留空即可。 + zh_Hant: 可選。掃碼綁定無法取得此欄位,目前介面卡實作亦未使用,留空即可。 + type: string + required: false + default: "" + - name: enable-webhook + label: + en_US: Enable Webhook Mode + zh_Hans: 启用Webhook模式 + zh_Hant: 啟用 Webhook 模式 + description: + en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WebSocket mode + zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WebSocket 模式 + zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WebSocket 模式 + type: boolean + required: true + default: false + - name: enable-stream-reply + label: + en_US: Enable Stream Reply Mode + zh_Hans: 启用流式回复模式 + zh_Hant: 啟用串流回覆模式 + description: + en_US: If enabled, the bot will use streaming mode to reply messages (C2C only) + zh_Hans: 如果启用,机器人将使用流式方式回复消息(仅私聊) + zh_Hant: 如果啟用,機器人將使用串流方式回覆訊息(僅私聊) + type: boolean + required: true + default: false + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL and paste it into your QQ Official API webhook configuration + zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中 + zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中 + type: webhook-url + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true +execution: + python: + path: ./qqofficial.py + attr: QQOfficialAdapter diff --git a/src/langbot/pkg/platform/sources/satori.png b/src/langbot/pkg/platform/sources/satori.png new file mode 100644 index 0000000..8a12cc6 Binary files /dev/null and b/src/langbot/pkg/platform/sources/satori.png differ diff --git a/src/langbot/pkg/platform/sources/satori.py b/src/langbot/pkg/platform/sources/satori.py new file mode 100644 index 0000000..6bfa342 --- /dev/null +++ b/src/langbot/pkg/platform/sources/satori.py @@ -0,0 +1,1090 @@ +from __future__ import annotations + +import typing +import time +import datetime +import json +import asyncio +import traceback +import re +import base64 + +import aiohttp +import pydantic +import websockets + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + + +class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + """Convert between LangBot MessageChain and Satori message format""" + + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain, adapter: 'SatoriAdapter') -> str: + """Convert LangBot MessageChain to Satori message format""" + content_parts = [] + + for component in message_chain: + if isinstance(component, platform_message.Plain): + text = component.text.replace('&', '&').replace('<', '<').replace('>', '>') + content_parts.append(text) + elif isinstance(component, platform_message.Image): + # Prefer URL over base64 to avoid buffer overflow issues with large images + if component.url: + content_parts.append(f'') + elif hasattr(component, 'base64') and component.base64: + # Process base64 data + base64_data = component.base64 + # Remove whitespace that might corrupt the data + base64_data = base64_data.replace('\n', '').replace('\r', '').replace(' ', '') + + # Check size - if too large, try to upload + MAX_INLINE_SIZE = 32 * 1024 # 32KB limit for inline base64 + + # Extract raw base64 and mime type + raw_b64 = base64_data + mime_type = 'image/png' + if base64_data.startswith('data:'): + try: + header, raw_b64 = base64_data.split(',', 1) + if ';' in header: + mime_type = header.split(':')[1].split(';')[0] + except (ValueError, IndexError): + pass + + if len(raw_b64) > MAX_INLINE_SIZE: + # Try to upload large image + try: + # Fix base64 padding if needed + padding = 4 - len(raw_b64) % 4 + if padding != 4: + raw_b64 += '=' * padding + image_bytes = base64.b64decode(raw_b64) + uploaded_url = await adapter.upload_image(image_bytes, mime_type) + if uploaded_url: + await adapter.logger.info(f'Satori 图片上传成功: {len(image_bytes)} 字节') + content_parts.append(f'') + else: + # Upload failed, use inline (may fail) + await adapter.logger.warning('Satori 图片上传失败,使用内联模式') + content_parts.append(f'') + except Exception as e: + await adapter.logger.error(f'Satori 图片处理失败: {e}') + content_parts.append(f'') + else: + # Small image, use inline + content_parts.append(f'') + elif isinstance(component, platform_message.At): + if component.target: + content_parts.append(f'') + elif isinstance(component, platform_message.AtAll): + content_parts.append('') + elif isinstance(component, platform_message.Reply): + content_parts.append(f'') + elif isinstance(component, platform_message.Quote): + content_parts.append(f'') + elif isinstance(component, platform_message.Face): + # Satori中的表情可以使用emoticon元素 + face_id = getattr(component, 'face_id', 'unknown') + content_parts.append(f'') + elif isinstance(component, platform_message.Voice): + if hasattr(component, 'url') and component.url: + content_parts.append(f'