mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-18 03:52:42 +08:00
Compare commits
8 Commits
fix/openco
...
codex/all-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
795c7b8bc2 | ||
|
|
b9a0267289 | ||
|
|
7a031b4b2d | ||
|
|
016ddb24c3 | ||
|
|
ab0beca54f | ||
|
|
5d8f729538 | ||
|
|
678ae093a1 | ||
|
|
7c47fa5d6d |
@@ -1,185 +0,0 @@
|
||||
---
|
||||
name: merge-pr
|
||||
description: Merge a GitHub PR via squash after /preparepr. Use when asked to merge a ready PR. Do not push to main or modify code. Ensure the PR ends in MERGED state and clean up worktrees after success.
|
||||
---
|
||||
|
||||
# Merge PR
|
||||
|
||||
## Overview
|
||||
|
||||
Merge a prepared PR via `gh pr merge --squash` and clean up the worktree after success.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Use `gh pr merge --squash` as the only path to `main`.
|
||||
- Do not run `git push` at all during merge.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/Development/openclaw`, not `~/openclaw`.
|
||||
- Read `.local/review.md` and `.local/prep.md` in the worktree. Do not skip.
|
||||
- Clean up the real worktree directory `.worktrees/pr-<PR>` only after a successful merge.
|
||||
- Expect cleanup to remove `.local/` artifacts.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Ensure `gh pr merge` succeeds.
|
||||
- Ensure PR state is `MERGED`, never `CLOSED`.
|
||||
- Record the merge SHA.
|
||||
- Run cleanup only after merge success.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all merge steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all merge work.
|
||||
|
||||
```sh
|
||||
cd ~/Development/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
|
||||
## Load Local Artifacts (Mandatory)
|
||||
|
||||
Expect these files from earlier steps:
|
||||
|
||||
- `.local/review.md` from `/reviewpr`
|
||||
- `.local/prep.md` from `/preparepr`
|
||||
|
||||
```sh
|
||||
ls -la .local || true
|
||||
|
||||
if [ -f .local/review.md ]; then
|
||||
echo "Found .local/review.md"
|
||||
sed -n '1,120p' .local/review.md
|
||||
else
|
||||
echo "Missing .local/review.md. Stop and run /reviewpr, then /preparepr."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f .local/prep.md ]; then
|
||||
echo "Found .local/prep.md"
|
||||
sed -n '1,120p' .local/prep.md
|
||||
else
|
||||
echo "Missing .local/prep.md. Stop and run /preparepr first."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,headRefName,baseRefName,headRepository,body --jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
```
|
||||
|
||||
2. Run sanity checks
|
||||
|
||||
Stop if any are true:
|
||||
|
||||
- PR is a draft.
|
||||
- Required checks are failing.
|
||||
- Branch is behind main.
|
||||
|
||||
```sh
|
||||
# Checks
|
||||
gh pr checks <PR>
|
||||
|
||||
# Check behind main
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
git merge-base --is-ancestor origin/main pr-<PR> || echo "PR branch is behind main, run /preparepr"
|
||||
```
|
||||
|
||||
If anything is failing or behind, stop and say to run `/preparepr`.
|
||||
|
||||
3. Merge PR and delete branch
|
||||
|
||||
If checks are still running, use `--auto` to queue the merge.
|
||||
|
||||
```sh
|
||||
# Check status first
|
||||
check_status=$(gh pr checks <PR> 2>&1)
|
||||
if echo "$check_status" | grep -q "pending\|queued"; then
|
||||
echo "Checks still running, using --auto to queue merge"
|
||||
gh pr merge <PR> --squash --delete-branch --auto
|
||||
echo "Merge queued. Monitor with: gh pr checks <PR> --watch"
|
||||
else
|
||||
gh pr merge <PR> --squash --delete-branch
|
||||
fi
|
||||
```
|
||||
|
||||
If merge fails, report the error and stop. Do not retry in a loop.
|
||||
If the PR needs changes beyond what `/preparepr` already did, stop and say to run `/preparepr` again.
|
||||
|
||||
4. Get merge SHA
|
||||
|
||||
```sh
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
echo "merge_sha=$merge_sha"
|
||||
```
|
||||
|
||||
5. Optional comment
|
||||
|
||||
Use a literal multiline string or heredoc for newlines.
|
||||
|
||||
```sh
|
||||
gh pr comment <PR> -F - <<'EOF'
|
||||
Merged via squash.
|
||||
|
||||
- Merge commit: $merge_sha
|
||||
|
||||
Thanks @$contrib!
|
||||
EOF
|
||||
```
|
||||
|
||||
6. Verify PR state is MERGED
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json state --jq .state
|
||||
```
|
||||
|
||||
7. Clean up worktree only on success
|
||||
|
||||
Run cleanup only if step 6 returned `MERGED`.
|
||||
|
||||
```sh
|
||||
cd ~/Development/openclaw
|
||||
|
||||
git worktree remove ".worktrees/pr-<PR>" --force
|
||||
|
||||
git branch -D temp/pr-<PR> 2>/dev/null || true
|
||||
git branch -D pr-<PR> 2>/dev/null || true
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not close PRs.
|
||||
- End in MERGED state.
|
||||
- Clean up only after merge success.
|
||||
- Never push to main. Use `gh pr merge --squash` only.
|
||||
- Do not run `git push` at all in this command.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Merge PR"
|
||||
short_description: "Merge GitHub PRs via squash"
|
||||
default_prompt: "Use $merge-pr to merge a GitHub PR via squash after preparation."
|
||||
@@ -1,248 +0,0 @@
|
||||
---
|
||||
name: prepare-pr
|
||||
description: Prepare a GitHub PR for merge by rebasing onto main, fixing review findings, running gates, committing fixes, and pushing to the PR head branch. Use after /reviewpr. Never merge or push to main.
|
||||
---
|
||||
|
||||
# Prepare PR
|
||||
|
||||
## Overview
|
||||
|
||||
Prepare a PR branch for merge with review fixes, green gates, and an updated head branch.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`. Push only to the PR head branch.
|
||||
- Never run `git push` without specifying remote and branch explicitly. Do not run bare `git push`.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`. Stage only specific files changed.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/openclaw`.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Rebase PR commits onto `origin/main`.
|
||||
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
|
||||
- Run gates and pass.
|
||||
- Commit prep changes.
|
||||
- Push the updated HEAD back to the PR head branch.
|
||||
- Write `.local/prep.md` with a prep summary.
|
||||
- Output exactly: `PR is ready for /mergepr`.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all prep steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all prep work.
|
||||
|
||||
```sh
|
||||
cd ~/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
|
||||
## Load Review Findings (Mandatory)
|
||||
|
||||
```sh
|
||||
if [ -f .local/review.md ]; then
|
||||
echo "Found review findings from /reviewpr"
|
||||
else
|
||||
echo "Missing .local/review.md. Run /reviewpr first and save findings."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read it
|
||||
sed -n '1,200p' .local/review.md
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta (author, head branch, head repo URL)
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository,body --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
```
|
||||
|
||||
2. Fetch the PR branch tip into a local ref
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
```
|
||||
|
||||
3. Rebase PR commits onto latest main
|
||||
|
||||
```sh
|
||||
# Move worktree to the PR tip first
|
||||
git reset --hard pr-<PR>
|
||||
|
||||
# Rebase onto current main
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
If conflicts happen:
|
||||
|
||||
- Resolve each conflicted file.
|
||||
- Run `git add <resolved_file>` for each file.
|
||||
- Run `git rebase --continue`.
|
||||
|
||||
If the rebase gets confusing or you resolve conflicts 3 or more times, stop and report.
|
||||
|
||||
4. Fix issues from `.local/review.md`
|
||||
|
||||
- Fix all BLOCKER and IMPORTANT items.
|
||||
- NITs are optional.
|
||||
- Keep scope tight.
|
||||
|
||||
Keep a running log in `.local/prep.md`:
|
||||
|
||||
- List which review items you fixed.
|
||||
- List which files you touched.
|
||||
- Note behavior changes.
|
||||
|
||||
5. Update `CHANGELOG.md` if flagged in review
|
||||
|
||||
Check `.local/review.md` section H for guidance.
|
||||
If flagged and user-facing:
|
||||
|
||||
- Check if `CHANGELOG.md` exists.
|
||||
|
||||
```sh
|
||||
ls CHANGELOG.md 2>/dev/null
|
||||
```
|
||||
|
||||
- Follow existing format.
|
||||
- Add a concise entry with PR number and contributor.
|
||||
|
||||
6. Update docs if flagged in review
|
||||
|
||||
Check `.local/review.md` section G for guidance.
|
||||
If flagged, update only docs related to the PR changes.
|
||||
|
||||
7. Commit prep fixes
|
||||
|
||||
Stage only specific files:
|
||||
|
||||
```sh
|
||||
git add <file1> <file2> ...
|
||||
```
|
||||
|
||||
Preferred commit tool:
|
||||
|
||||
```sh
|
||||
committer "fix: <summary> (#<PR>) (thanks @$contrib)" <changed files>
|
||||
```
|
||||
|
||||
If `committer` is not found:
|
||||
|
||||
```sh
|
||||
git commit -m "fix: <summary> (#<PR>) (thanks @$contrib)"
|
||||
```
|
||||
|
||||
8. Run full gates before pushing
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
pnpm check
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Require all to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix and rerun cycles. If gates still fail after 3 attempts, stop and report the failures. Do not loop indefinitely.
|
||||
|
||||
9. Push updates back to the PR head branch
|
||||
|
||||
```sh
|
||||
# Ensure remote for PR head exists
|
||||
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
|
||||
|
||||
# Use force with lease after rebase
|
||||
# Double check: $head must NOT be "main" or "master"
|
||||
echo "Pushing to branch: $head"
|
||||
if [ "$head" = "main" ] || [ "$head" = "master" ]; then
|
||||
echo "ERROR: head branch is main/master. This is wrong. Stopping."
|
||||
exit 1
|
||||
fi
|
||||
git push --force-with-lease prhead HEAD:$head
|
||||
```
|
||||
|
||||
10. Verify PR is not behind main (Mandatory)
|
||||
|
||||
```sh
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>-verify --force
|
||||
git merge-base --is-ancestor origin/main pr-<PR>-verify && echo "PR is up to date with main" || echo "ERROR: PR is still behind main, rebase again"
|
||||
git branch -D pr-<PR>-verify 2>/dev/null || true
|
||||
```
|
||||
|
||||
If still behind main, repeat steps 2 through 9.
|
||||
|
||||
11. Write prep summary artifacts (Mandatory)
|
||||
|
||||
Update `.local/prep.md` with:
|
||||
|
||||
- Current HEAD sha from `git rev-parse HEAD`.
|
||||
- Short bullet list of changes.
|
||||
- Gate results.
|
||||
- Push confirmation.
|
||||
- Rebase verification result.
|
||||
|
||||
Create or overwrite `.local/prep.md` and verify it exists and is non-empty:
|
||||
|
||||
```sh
|
||||
git rev-parse HEAD
|
||||
ls -la .local/prep.md
|
||||
wc -l .local/prep.md
|
||||
```
|
||||
|
||||
12. Output
|
||||
|
||||
Include a diff stat summary:
|
||||
|
||||
```sh
|
||||
git diff --stat origin/main..HEAD
|
||||
git diff --shortstat origin/main..HEAD
|
||||
```
|
||||
|
||||
Report totals: X files changed, Y insertions(+), Z deletions(-).
|
||||
|
||||
If gates passed and push succeeded, print exactly:
|
||||
|
||||
```
|
||||
PR is ready for /mergepr
|
||||
```
|
||||
|
||||
Otherwise, list remaining failures and stop.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree on success. `/mergepr` may reuse it.
|
||||
- Do not run `gh pr merge`.
|
||||
- Never push to main. Only push to the PR head branch.
|
||||
- Run and pass all gates before pushing.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Prepare PR"
|
||||
short_description: "Prepare GitHub PRs for merge"
|
||||
default_prompt: "Use $prepare-pr to prep a GitHub PR for merge without merging."
|
||||
@@ -1,228 +0,0 @@
|
||||
---
|
||||
name: review-pr
|
||||
description: Review-only GitHub pull request analysis with the gh CLI. Use when asked to review a PR, provide structured feedback, or assess readiness to land. Do not merge, push, or make code changes you intend to keep.
|
||||
---
|
||||
|
||||
# Review PR
|
||||
|
||||
## Overview
|
||||
|
||||
Perform a thorough review-only PR assessment and return a structured recommendation on readiness for /preparepr.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, always ask. Never auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`, not during review, not ever.
|
||||
- Do not run `git push` at all during review. Treat review as read only.
|
||||
- Do not stop or kill the gateway. Do not run gateway stop commands. Do not kill processes on port 18792.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs, not a plan.
|
||||
|
||||
## Known Failure Modes
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/openclaw`.
|
||||
- Do not stop after printing the checklist. That is not completion.
|
||||
|
||||
## Writing Style for Output
|
||||
|
||||
- Write casual and direct.
|
||||
- Avoid em dashes and en dashes. Use commas or separate sentences.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Run the commands in the worktree and inspect the PR directly.
|
||||
- Produce the structured review sections A through J.
|
||||
- Save the full review to `.local/review.md` inside the worktree.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all review steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all review work.
|
||||
|
||||
```sh
|
||||
cd ~/Development/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
git fetch origin main
|
||||
|
||||
# Reuse existing worktree if it exists, otherwise create new
|
||||
if [ -d "$WORKTREE_DIR" ]; then
|
||||
cd "$WORKTREE_DIR"
|
||||
git checkout temp/pr-<PR> 2>/dev/null || git checkout -b temp/pr-<PR>
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
else
|
||||
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
|
||||
cd "$WORKTREE_DIR"
|
||||
fi
|
||||
|
||||
# Create local scratch space that persists across /reviewpr to /preparepr to /mergepr
|
||||
mkdir -p .local
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
Start on `origin/main` so you can check for existing implementations before looking at PR code.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta and context
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length,body}'
|
||||
```
|
||||
|
||||
2. Check if this already exists in main before looking at the PR branch
|
||||
|
||||
- Identify the core feature or fix from the PR title and description.
|
||||
- Search for existing implementations using keywords from the PR title, changed file paths, and function or component names from the diff.
|
||||
|
||||
```sh
|
||||
# Use keywords from the PR title and changed files
|
||||
rg -n "<keyword_from_pr_title>" -S src packages apps ui || true
|
||||
rg -n "<function_or_component_name>" -S src packages apps ui || true
|
||||
|
||||
git log --oneline --all --grep="<keyword_from_pr_title>" | head -20
|
||||
```
|
||||
|
||||
If it already exists, call it out as a BLOCKER or at least IMPORTANT.
|
||||
|
||||
3. Claim the PR
|
||||
|
||||
Assign yourself so others know someone is reviewing. Skip if the PR looks like spam or is a draft you plan to recommend closing.
|
||||
|
||||
```sh
|
||||
gh_user=$(gh api user --jq .login)
|
||||
gh pr edit <PR> --add-assignee "$gh_user"
|
||||
```
|
||||
|
||||
4. Read the PR description carefully
|
||||
|
||||
Use the body from step 1. Summarize goal, scope, and missing context.
|
||||
|
||||
5. Read the diff thoroughly
|
||||
|
||||
Minimum:
|
||||
|
||||
```sh
|
||||
gh pr diff <PR>
|
||||
```
|
||||
|
||||
If you need full code context locally, fetch the PR head to a local ref and diff it. Do not create a merge commit.
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
# Show changes without modifying the working tree
|
||||
|
||||
git diff --stat origin/main..pr-<PR>
|
||||
git diff origin/main..pr-<PR>
|
||||
```
|
||||
|
||||
If you want to browse the PR version of files directly, temporarily check out `pr-<PR>` in the worktree. Do not commit or push. Return to `temp/pr-<PR>` and reset to `origin/main` afterward.
|
||||
|
||||
```sh
|
||||
# Use only if needed
|
||||
# git checkout pr-<PR>
|
||||
# ...inspect files...
|
||||
|
||||
git checkout temp/pr-<PR>
|
||||
git reset --hard origin/main
|
||||
```
|
||||
|
||||
6. Validate the change is needed and valuable
|
||||
|
||||
Be honest. Call out low value AI slop.
|
||||
|
||||
7. Evaluate implementation quality
|
||||
|
||||
Review correctness, design, performance, and ergonomics.
|
||||
|
||||
8. Perform a security review
|
||||
|
||||
Assume OpenClaw subagents run with full disk access, including git, gh, and shell. Check auth, input validation, secrets, dependencies, tool safety, and privacy.
|
||||
|
||||
9. Review tests and verification
|
||||
|
||||
Identify what exists, what is missing, and what would be a minimal regression test.
|
||||
|
||||
10. Check docs
|
||||
|
||||
Check if the PR touches code with related documentation such as README, docs, inline API docs, or config examples.
|
||||
|
||||
- If docs exist for the changed area and the PR does not update them, flag as IMPORTANT.
|
||||
- If the PR adds a new feature or config option with no docs, flag as IMPORTANT.
|
||||
- If the change is purely internal with no user-facing impact, skip this.
|
||||
|
||||
11. Check changelog
|
||||
|
||||
Check if `CHANGELOG.md` exists and whether the PR warrants an entry.
|
||||
|
||||
- If the project has a changelog and the PR is user-facing, flag missing entry as IMPORTANT.
|
||||
- Leave the change for /preparepr, only flag it here.
|
||||
|
||||
12. Answer the key question
|
||||
|
||||
Decide if /preparepr can fix issues or the contributor must update the PR.
|
||||
|
||||
13. Save findings to the worktree
|
||||
|
||||
Write the full structured review sections A through J to `.local/review.md`.
|
||||
Create or overwrite the file and verify it exists and is non-empty.
|
||||
|
||||
```sh
|
||||
ls -la .local/review.md
|
||||
wc -l .local/review.md
|
||||
```
|
||||
|
||||
14. Output the structured review
|
||||
|
||||
Produce a review that matches what you saved to `.local/review.md`.
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /preparepr | NEEDS WORK | NEEDS DISCUSSION | NOT USEFUL (CLOSE)
|
||||
- 1 to 3 sentences.
|
||||
|
||||
B) What changed
|
||||
|
||||
C) What is good
|
||||
|
||||
D) Security findings
|
||||
|
||||
E) Concerns or questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as BLOCKER, IMPORTANT, or NIT.
|
||||
- For each, point to file or area and propose a concrete fix.
|
||||
|
||||
F) Tests
|
||||
|
||||
G) Docs status
|
||||
|
||||
- State if related docs are up to date, missing, or not applicable.
|
||||
|
||||
H) Changelog
|
||||
|
||||
- State if `CHANGELOG.md` needs an entry and which category.
|
||||
|
||||
I) Follow ups (optional)
|
||||
|
||||
J) Suggested PR comment (optional)
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree after review.
|
||||
- Review only, do not merge, do not push.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Review PR"
|
||||
short_description: "Review GitHub PRs without merging"
|
||||
default_prompt: "Use $review-pr to perform a thorough, review-only GitHub PR review."
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -64,6 +64,7 @@ apps/ios/*.mobileprovision
|
||||
|
||||
# Local untracked files
|
||||
.local/
|
||||
.vscode/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["oxc.oxc-vscode"]
|
||||
}
|
||||
22
.vscode/settings.json
vendored
22
.vscode/settings.json
vendored
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifierEnding": "js",
|
||||
"typescript.reportStyleChecksAsWarnings": false,
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.experimental.useTsgo": true
|
||||
}
|
||||
@@ -58,7 +58,6 @@
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Type-check/build: `pnpm build`
|
||||
- TypeScript checks: `pnpm tsgo`
|
||||
- Lint/format: `pnpm check`
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -6,23 +6,18 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Agents: bump pi-mono packages to 0.52.5. (#9949) Thanks @gumadeiras.
|
||||
- Models: default Anthropic model to `anthropic/claude-opus-4-6`. (#9853) Thanks @TinyTb.
|
||||
- Models/Onboarding: refresh provider defaults, update OpenAI/OpenAI Codex wizard defaults, and harden model allowlist initialization for first-time configs with matching docs/tests. (#9911) Thanks @gumadeiras.
|
||||
- Models/Onboarding: refresh provider defaults to Opus 4.6 and update OpenAI/OpenAI Codex wizard defaults (`openai/gpt-5.1-codex`, `openai-codex/gpt-5.3-codex`) with matching docs/tests. (#9898) Thanks @gumadeiras.
|
||||
- Telegram: auto-inject forum topic `threadId` in message tool and subagent announce so media, buttons, and subagent results land in the correct topic instead of General. (#7235) Thanks @Lukavyi.
|
||||
- Security: add skill/plugin code safety scanner that detects dangerous patterns (command injection, eval, data exfiltration, obfuscated code, crypto mining, env harvesting) in installed extensions. Integrated into `openclaw security audit --deep` and plugin install flow; scan failures surface as warnings. (#9806) Thanks @abdelsfane.
|
||||
- CLI: sort `openclaw --help` commands (and options) alphabetically. (#8068) Thanks @deepsoumya617.
|
||||
- Telegram: remove last `@ts-nocheck` from `bot-handlers.ts`, use Grammy types directly, deduplicate `StickerMetadata`. Zero `@ts-nocheck` remaining in `src/telegram/`. (#9206)
|
||||
- Telegram: remove `@ts-nocheck` from `bot-message.ts`, type deps via `Omit<BuildTelegramMessageContextParams>`, widen `allMedia` to `TelegramMediaRef[]`. (#9180)
|
||||
- Telegram: remove `@ts-nocheck` from `bot.ts`, fix duplicate `bot.catch` error handler (Grammy overrides), remove dead reaction `message_thread_id` routing, harden sticker cache guard. (#9077)
|
||||
- Telegram: allow per-group and per-topic `groupPolicy` overrides under `channels.telegram.groups`. (#9775) Thanks @nicolasstanley.
|
||||
- Feishu: expand channel handling (posts with images, doc links, routing, reactions/typing, replies, native commands). (#8975) Thanks @jiulingyun.
|
||||
- Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.
|
||||
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
|
||||
- Onboarding: add xAI (Grok) auth choice and provider defaults. (#9885) Thanks @grp06.
|
||||
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
|
||||
- Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.
|
||||
- Docs: strengthen secure DM mode guidance for multi-user inboxes with an explicit warning and example. (#9377) Thanks @Shrinija17.
|
||||
- Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.
|
||||
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
|
||||
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
|
||||
@@ -33,14 +28,11 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Models: add forward-compat fallback for `openai-codex/gpt-5.3-codex` when model registry hasn't discovered it yet. (#9989) Thanks @w1kke.
|
||||
- Auto-reply/Docs: normalize `extra-high` (and spaced variants) to `xhigh` for Codex thinking levels, and align Codex 5.3 FAQ examples. (#9976) Thanks @slonce70.
|
||||
- Compaction: remove orphaned `tool_result` messages during history pruning to prevent session corruption from aborted tool calls. (#9868, fixes #9769, #9724, #9672)
|
||||
- Telegram: pass `parentPeer` for forum topic binding inheritance so group-level bindings apply to all topics within the group. (#9789, fixes #9545, #9351)
|
||||
- CLI: pass `--disable-warning=ExperimentalWarning` as a Node CLI option when respawning (avoid disallowed `NODE_OPTIONS` usage; fixes npm pack). (#9691) Thanks @18-RAJAT.
|
||||
- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB.
|
||||
- Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682.
|
||||
- Exec approvals: coerce bare string allowlist entries to objects to prevent allowlist corruption. (#9903, fixes #9790) Thanks @mcaxtr.
|
||||
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
|
||||
- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.
|
||||
- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.
|
||||
@@ -50,9 +42,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Web UI: apply button styling to the new-messages indicator.
|
||||
- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua.
|
||||
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
|
||||
- Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane.
|
||||
- Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted.
|
||||
- Slack: strip `<@...>` mention tokens before command matching so `/new` and `/reset` work when prefixed with a mention. (#9971) Thanks @ironbyte-rgb.
|
||||
- Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.
|
||||
- Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.
|
||||
- Security: gate `whatsapp_login` tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.
|
||||
@@ -60,13 +50,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier.
|
||||
- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing.
|
||||
- Cron: reload store data when the store file is recreated or mtime changes.
|
||||
- Cron: prevent `recomputeNextRuns` from skipping due jobs when timer fires late by reordering `onTimer` flow. (#9823, fixes #9788) Thanks @pycckuu.
|
||||
- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
|
||||
- Cron: correct announce delivery inference for thread session keys and null delivery inputs. (#9733) Thanks @tyler6204.
|
||||
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
|
||||
- Telegram: preserve DM topic threadId in deliveryContext. (#9039) Thanks @lailoo.
|
||||
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
|
||||
- Security: require gateway auth for Canvas host and A2UI assets. (#9518) Thanks @coygeek.
|
||||
|
||||
## 2026.2.2-3
|
||||
|
||||
|
||||
@@ -392,23 +392,6 @@ Two independent controls:
|
||||
|
||||
Most users want: `groupPolicy: "allowlist"` + `groupAllowFrom` + specific groups listed in `channels.telegram.groups`
|
||||
|
||||
To allow **any group member** to talk in a specific group (while still keeping control commands restricted to authorized senders), set a per-group override:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
groupPolicy: "open",
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Long-polling vs webhook
|
||||
|
||||
- Default: long-polling (no public URL required).
|
||||
@@ -731,14 +714,12 @@ Provider options:
|
||||
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (ids/usernames).
|
||||
- `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
|
||||
- `channels.telegram.groups.<id>.groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.requireMention`: mention gating default.
|
||||
- `channels.telegram.groups.<id>.skills`: skill filter (omit = all skills, empty = none).
|
||||
- `channels.telegram.groups.<id>.allowFrom`: per-group sender allowlist override.
|
||||
- `channels.telegram.groups.<id>.systemPrompt`: extra system prompt for the group.
|
||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
|
||||
@@ -17,17 +17,9 @@ Use `session.dmScope` to control how **direct messages** are grouped:
|
||||
- `per-account-channel-peer`: isolate by account + channel + sender (recommended for multi-account inboxes).
|
||||
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
|
||||
|
||||
### Secure DM mode (recommended for multi-user setups)
|
||||
### Secure DM mode (recommended)
|
||||
|
||||
> **Security Warning:** If your agent can receive DMs from **multiple people**, you should strongly consider enabling secure DM mode. Without it, all users share the same conversation context, which can leak private information between users.
|
||||
|
||||
**Example of the problem with default settings:**
|
||||
|
||||
- Alice (`<SENDER_A>`) messages your agent about a private topic (for example, a medical appointment)
|
||||
- Bob (`<SENDER_B>`) messages your agent asking "What were we talking about?"
|
||||
- Because both DMs share the same session, the model may answer Bob using Alice's prior context.
|
||||
|
||||
**The fix:** Set `dmScope` to isolate sessions per user:
|
||||
If your agent can receive DMs from **multiple people** (pairing approvals for more than one sender, a DM allowlist with multiple entries, or `dmPolicy: "open"`), enable **secure DM mode** to avoid cross-user context leakage:
|
||||
|
||||
```json5
|
||||
// ~/.openclaw/openclaw.json
|
||||
@@ -39,19 +31,11 @@ Use `session.dmScope` to control how **direct messages** are grouped:
|
||||
}
|
||||
```
|
||||
|
||||
**When to enable this:**
|
||||
|
||||
- You have pairing approvals for more than one sender
|
||||
- You use a DM allowlist with multiple entries
|
||||
- You set `dmPolicy: "open"`
|
||||
- Multiple phone numbers or accounts can message your agent
|
||||
|
||||
Notes:
|
||||
|
||||
- Default is `dmScope: "main"` for continuity (all DMs share the main session). This is fine for single-user setups.
|
||||
- Default is `dmScope: "main"` for continuity (all DMs share the main session).
|
||||
- For multi-account inboxes on the same channel, prefer `per-account-channel-peer`.
|
||||
- If the same person contacts you on multiple channels, use `session.identityLinks` to collapse their DM sessions into one canonical identity.
|
||||
- You can verify your DM settings with `openclaw security audit` (see [security](/cli/security)).
|
||||
|
||||
## Gateway is the source of truth
|
||||
|
||||
|
||||
321
docs/docs.json
321
docs/docs.json
@@ -723,52 +723,20 @@
|
||||
"destination": "/plugin"
|
||||
},
|
||||
{
|
||||
"source": "/railway",
|
||||
"destination": "/install/railway"
|
||||
"source": "/install/railway",
|
||||
"destination": "/railway"
|
||||
},
|
||||
{
|
||||
"source": "/northflank",
|
||||
"destination": "/install/northflank"
|
||||
"source": "/install/northflank",
|
||||
"destination": "/northflank"
|
||||
},
|
||||
{
|
||||
"source": "/render",
|
||||
"destination": "/install/render"
|
||||
"source": "/install/northflank/",
|
||||
"destination": "/northflank"
|
||||
},
|
||||
{
|
||||
"source": "/gcp",
|
||||
"destination": "/install/gcp"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/fly",
|
||||
"destination": "/install/fly"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/hetzner",
|
||||
"destination": "/install/hetzner"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/gcp",
|
||||
"destination": "/install/gcp"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/macos-vm",
|
||||
"destination": "/install/macos-vm"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/exe-dev",
|
||||
"destination": "/install/exe-dev"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/railway",
|
||||
"destination": "/install/railway"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/render",
|
||||
"destination": "/install/render"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/northflank",
|
||||
"destination": "/install/northflank"
|
||||
"destination": "/platforms/gcp"
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
@@ -781,14 +749,35 @@
|
||||
"groups": [
|
||||
{
|
||||
"group": "Overview",
|
||||
"pages": ["index", "concepts/features", "start/showcase"]
|
||||
"pages": ["index", "concepts/features", "start/showcase", "start/lore"]
|
||||
},
|
||||
{
|
||||
"group": "First steps",
|
||||
"pages": ["start/getting-started", "start/wizard", "start/onboarding"]
|
||||
"group": "First run",
|
||||
"pages": [
|
||||
"start/getting-started",
|
||||
{
|
||||
"group": "Onboarding",
|
||||
"pages": [
|
||||
{
|
||||
"group": "CLI onboarding",
|
||||
"pages": [
|
||||
"start/wizard",
|
||||
"start/wizard-cli-reference",
|
||||
"start/wizard-cli-automation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "macOS onboarding",
|
||||
"pages": ["start/onboarding"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"start/bootstrapping",
|
||||
"start/pairing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Guides",
|
||||
"group": "Use cases",
|
||||
"pages": ["start/openclaw"]
|
||||
}
|
||||
]
|
||||
@@ -814,19 +803,6 @@
|
||||
"group": "Maintenance",
|
||||
"pages": ["install/updating", "install/migrating", "install/uninstall"]
|
||||
},
|
||||
{
|
||||
"group": "Hosting and deployment",
|
||||
"pages": [
|
||||
"install/fly",
|
||||
"install/hetzner",
|
||||
"install/gcp",
|
||||
"install/macos-vm",
|
||||
"install/exe-dev",
|
||||
"install/railway",
|
||||
"install/render",
|
||||
"install/northflank"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Advanced",
|
||||
"pages": ["install/development-channels"]
|
||||
@@ -863,7 +839,6 @@
|
||||
{
|
||||
"group": "Configuration",
|
||||
"pages": [
|
||||
"start/pairing",
|
||||
"concepts/group-messages",
|
||||
"concepts/groups",
|
||||
"broadcast-groups",
|
||||
@@ -886,7 +861,6 @@
|
||||
"concepts/system-prompt",
|
||||
"concepts/context",
|
||||
"concepts/agent-workspace",
|
||||
"start/bootstrapping",
|
||||
"concepts/oauth"
|
||||
]
|
||||
},
|
||||
@@ -1017,45 +991,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Platforms",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Platforms overview",
|
||||
"pages": [
|
||||
"platforms/index",
|
||||
"platforms/macos",
|
||||
"platforms/linux",
|
||||
"platforms/windows",
|
||||
"platforms/android",
|
||||
"platforms/ios"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "macOS companion app",
|
||||
"pages": [
|
||||
"platforms/mac/dev-setup",
|
||||
"platforms/mac/menu-bar",
|
||||
"platforms/mac/voicewake",
|
||||
"platforms/mac/voice-overlay",
|
||||
"platforms/mac/webchat",
|
||||
"platforms/mac/canvas",
|
||||
"platforms/mac/child-process",
|
||||
"platforms/mac/health",
|
||||
"platforms/mac/icon",
|
||||
"platforms/mac/logging",
|
||||
"platforms/mac/permissions",
|
||||
"platforms/mac/remote",
|
||||
"platforms/mac/signing",
|
||||
"platforms/mac/release",
|
||||
"platforms/mac/bundled-gateway",
|
||||
"platforms/mac/xpc",
|
||||
"platforms/mac/skills",
|
||||
"platforms/mac/peekaboo"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Gateway & Ops",
|
||||
"groups": [
|
||||
@@ -1110,8 +1045,20 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Remote access",
|
||||
"pages": ["gateway/remote", "gateway/remote-gateway-readme", "gateway/tailscale"]
|
||||
"group": "Remote access and deployment",
|
||||
"pages": [
|
||||
"gateway/remote",
|
||||
"gateway/remote-gateway-readme",
|
||||
"gateway/tailscale",
|
||||
"platforms/fly",
|
||||
"platforms/hetzner",
|
||||
"platforms/gcp",
|
||||
"platforms/macos-vm",
|
||||
"platforms/exe-dev",
|
||||
"railway",
|
||||
"render",
|
||||
"northflank"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Security",
|
||||
@@ -1123,6 +1070,45 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Platforms",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Platforms overview",
|
||||
"pages": [
|
||||
"platforms/index",
|
||||
"platforms/macos",
|
||||
"platforms/linux",
|
||||
"platforms/windows",
|
||||
"platforms/android",
|
||||
"platforms/ios"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "macOS companion app",
|
||||
"pages": [
|
||||
"platforms/mac/dev-setup",
|
||||
"platforms/mac/menu-bar",
|
||||
"platforms/mac/voicewake",
|
||||
"platforms/mac/voice-overlay",
|
||||
"platforms/mac/webchat",
|
||||
"platforms/mac/canvas",
|
||||
"platforms/mac/child-process",
|
||||
"platforms/mac/health",
|
||||
"platforms/mac/icon",
|
||||
"platforms/mac/logging",
|
||||
"platforms/mac/permissions",
|
||||
"platforms/mac/remote",
|
||||
"platforms/mac/signing",
|
||||
"platforms/mac/release",
|
||||
"platforms/mac/bundled-gateway",
|
||||
"platforms/mac/xpc",
|
||||
"platforms/mac/skills",
|
||||
"platforms/mac/peekaboo"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Reference",
|
||||
"groups": [
|
||||
@@ -1188,7 +1174,6 @@
|
||||
{
|
||||
"group": "Technical reference",
|
||||
"pages": [
|
||||
"reference/wizard",
|
||||
"concepts/typebox",
|
||||
"concepts/markdown-formatting",
|
||||
"concepts/typing-indicators",
|
||||
@@ -1214,10 +1199,6 @@
|
||||
"group": "Help",
|
||||
"pages": ["help/index", "help/troubleshooting", "help/faq"]
|
||||
},
|
||||
{
|
||||
"group": "Community",
|
||||
"pages": ["start/lore"]
|
||||
},
|
||||
{
|
||||
"group": "Environment and debugging",
|
||||
"pages": [
|
||||
@@ -1248,18 +1229,26 @@
|
||||
"groups": [
|
||||
{
|
||||
"group": "概览",
|
||||
"pages": ["zh-CN/index", "zh-CN/concepts/features", "zh-CN/start/showcase"]
|
||||
},
|
||||
{
|
||||
"group": "第一步",
|
||||
"pages": [
|
||||
"zh-CN/start/getting-started",
|
||||
"zh-CN/start/wizard",
|
||||
"zh-CN/start/onboarding"
|
||||
"zh-CN/index",
|
||||
"zh-CN/concepts/features",
|
||||
"zh-CN/start/showcase",
|
||||
"zh-CN/start/lore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "指南",
|
||||
"group": "首次运行",
|
||||
"pages": [
|
||||
"zh-CN/start/getting-started",
|
||||
{
|
||||
"group": "新手引导",
|
||||
"pages": ["zh-CN/start/wizard", "zh-CN/start/onboarding"]
|
||||
},
|
||||
"zh-CN/start/pairing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "使用场景",
|
||||
"pages": ["zh-CN/start/openclaw"]
|
||||
}
|
||||
]
|
||||
@@ -1289,19 +1278,6 @@
|
||||
"zh-CN/install/uninstall"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "托管与部署",
|
||||
"pages": [
|
||||
"zh-CN/install/fly",
|
||||
"zh-CN/install/hetzner",
|
||||
"zh-CN/install/gcp",
|
||||
"zh-CN/install/macos-vm",
|
||||
"zh-CN/install/exe-dev",
|
||||
"zh-CN/install/railway",
|
||||
"zh-CN/install/render",
|
||||
"zh-CN/install/northflank"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "高级",
|
||||
"pages": ["zh-CN/install/development-channels"]
|
||||
@@ -1338,7 +1314,6 @@
|
||||
{
|
||||
"group": "配置",
|
||||
"pages": [
|
||||
"zh-CN/start/pairing",
|
||||
"zh-CN/concepts/group-messages",
|
||||
"zh-CN/concepts/groups",
|
||||
"zh-CN/broadcast-groups",
|
||||
@@ -1499,45 +1474,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "平台",
|
||||
"groups": [
|
||||
{
|
||||
"group": "平台概览",
|
||||
"pages": [
|
||||
"zh-CN/platforms/index",
|
||||
"zh-CN/platforms/macos",
|
||||
"zh-CN/platforms/linux",
|
||||
"zh-CN/platforms/windows",
|
||||
"zh-CN/platforms/android",
|
||||
"zh-CN/platforms/ios"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "macOS 配套应用",
|
||||
"pages": [
|
||||
"zh-CN/platforms/mac/dev-setup",
|
||||
"zh-CN/platforms/mac/menu-bar",
|
||||
"zh-CN/platforms/mac/voicewake",
|
||||
"zh-CN/platforms/mac/voice-overlay",
|
||||
"zh-CN/platforms/mac/webchat",
|
||||
"zh-CN/platforms/mac/canvas",
|
||||
"zh-CN/platforms/mac/child-process",
|
||||
"zh-CN/platforms/mac/health",
|
||||
"zh-CN/platforms/mac/icon",
|
||||
"zh-CN/platforms/mac/logging",
|
||||
"zh-CN/platforms/mac/permissions",
|
||||
"zh-CN/platforms/mac/remote",
|
||||
"zh-CN/platforms/mac/signing",
|
||||
"zh-CN/platforms/mac/release",
|
||||
"zh-CN/platforms/mac/bundled-gateway",
|
||||
"zh-CN/platforms/mac/xpc",
|
||||
"zh-CN/platforms/mac/skills",
|
||||
"zh-CN/platforms/mac/peekaboo"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "网关与运维",
|
||||
"groups": [
|
||||
@@ -1592,11 +1528,19 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "远程访问",
|
||||
"group": "远程访问与部署",
|
||||
"pages": [
|
||||
"zh-CN/gateway/remote",
|
||||
"zh-CN/gateway/remote-gateway-readme",
|
||||
"zh-CN/gateway/tailscale"
|
||||
"zh-CN/gateway/tailscale",
|
||||
"zh-CN/platforms/fly",
|
||||
"zh-CN/platforms/hetzner",
|
||||
"zh-CN/platforms/gcp",
|
||||
"zh-CN/platforms/macos-vm",
|
||||
"zh-CN/platforms/exe-dev",
|
||||
"zh-CN/railway",
|
||||
"zh-CN/render",
|
||||
"zh-CN/northflank"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1615,6 +1559,45 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "平台",
|
||||
"groups": [
|
||||
{
|
||||
"group": "平台概览",
|
||||
"pages": [
|
||||
"zh-CN/platforms/index",
|
||||
"zh-CN/platforms/macos",
|
||||
"zh-CN/platforms/linux",
|
||||
"zh-CN/platforms/windows",
|
||||
"zh-CN/platforms/android",
|
||||
"zh-CN/platforms/ios"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "macOS 配套应用",
|
||||
"pages": [
|
||||
"zh-CN/platforms/mac/dev-setup",
|
||||
"zh-CN/platforms/mac/menu-bar",
|
||||
"zh-CN/platforms/mac/voicewake",
|
||||
"zh-CN/platforms/mac/voice-overlay",
|
||||
"zh-CN/platforms/mac/webchat",
|
||||
"zh-CN/platforms/mac/canvas",
|
||||
"zh-CN/platforms/mac/child-process",
|
||||
"zh-CN/platforms/mac/health",
|
||||
"zh-CN/platforms/mac/icon",
|
||||
"zh-CN/platforms/mac/logging",
|
||||
"zh-CN/platforms/mac/permissions",
|
||||
"zh-CN/platforms/mac/remote",
|
||||
"zh-CN/platforms/mac/signing",
|
||||
"zh-CN/platforms/mac/release",
|
||||
"zh-CN/platforms/mac/bundled-gateway",
|
||||
"zh-CN/platforms/mac/xpc",
|
||||
"zh-CN/platforms/mac/skills",
|
||||
"zh-CN/platforms/mac/peekaboo"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "参考",
|
||||
"groups": [
|
||||
@@ -1705,10 +1688,6 @@
|
||||
"group": "帮助",
|
||||
"pages": ["zh-CN/help/index", "zh-CN/help/troubleshooting", "zh-CN/help/faq"]
|
||||
},
|
||||
{
|
||||
"group": "社区",
|
||||
"pages": ["zh-CN/start/lore"]
|
||||
},
|
||||
{
|
||||
"group": "环境与调试",
|
||||
"pages": [
|
||||
|
||||
@@ -28,7 +28,7 @@ Run the Gateway on a persistent host and reach it via **Tailscale** or SSH.
|
||||
|
||||
- **Best UX:** keep `gateway.bind: "loopback"` and use **Tailscale Serve** for the Control UI.
|
||||
- **Fallback:** keep loopback + SSH tunnel from any machine that needs access.
|
||||
- **Examples:** [exe.dev](/install/exe-dev) (easy VM) or [Hetzner](/install/hetzner) (production VPS).
|
||||
- **Examples:** [exe.dev](/platforms/exe-dev) (easy VM) or [Hetzner](/platforms/hetzner) (production VPS).
|
||||
|
||||
This is ideal when your laptop sleeps often but you want the agent always-on.
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Can I use self-hosted models (llama.cpp, vLLM, Ollama)?](#can-i-use-selfhosted-models-llamacpp-vllm-ollama)
|
||||
- [What do OpenClaw, Flawd, and Krill use for models?](#what-do-openclaw-flawd-and-krill-use-for-models)
|
||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||
- [Can I use GPT 5.2 for daily tasks and Codex 5.3 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-53-for-coding)
|
||||
- [Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-52-for-coding)
|
||||
- [Why do I see "Model … is not allowed" and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
||||
- [Why do I see "Unknown model: minimax/MiniMax-M2.1"?](#why-do-i-see-unknown-model-minimaxminimaxm21)
|
||||
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
|
||||
@@ -591,7 +591,7 @@ Short answer: follow the Linux guide, then run the onboarding wizard.
|
||||
|
||||
Any Linux VPS works. Install on the server, then use SSH/Tailscale to reach the Gateway.
|
||||
|
||||
Guides: [exe.dev](/install/exe-dev), [Hetzner](/install/hetzner), [Fly.io](/install/fly).
|
||||
Guides: [exe.dev](/platforms/exe-dev), [Hetzner](/platforms/hetzner), [Fly.io](/platforms/fly).
|
||||
Remote access: [Gateway remote](/gateway/remote).
|
||||
|
||||
### Where are the cloudVPS install guides
|
||||
@@ -599,9 +599,9 @@ Remote access: [Gateway remote](/gateway/remote).
|
||||
We keep a **hosting hub** with the common providers. Pick one and follow the guide:
|
||||
|
||||
- [VPS hosting](/vps) (all providers in one place)
|
||||
- [Fly.io](/install/fly)
|
||||
- [Hetzner](/install/hetzner)
|
||||
- [exe.dev](/install/exe-dev)
|
||||
- [Fly.io](/platforms/fly)
|
||||
- [Hetzner](/platforms/hetzner)
|
||||
- [exe.dev](/platforms/exe-dev)
|
||||
|
||||
How it works in the cloud: the **Gateway runs on the server**, and you access it
|
||||
from your laptop/phone via the Control UI (or Tailscale/SSH). Your state + workspace
|
||||
@@ -910,7 +910,7 @@ Baseline guidance:
|
||||
|
||||
If you are on Windows, **WSL2 is the easiest VM style setup** and has the best tooling
|
||||
compatibility. See [Windows](/platforms/windows), [VPS hosting](/vps).
|
||||
If you are running macOS in a VM, see [macOS VM](/install/macos-vm).
|
||||
If you are running macOS in a VM, see [macOS VM](/platforms/macos-vm).
|
||||
|
||||
## What is OpenClaw?
|
||||
|
||||
@@ -2035,12 +2035,12 @@ Re-run `/model` **without** the `@profile` suffix:
|
||||
If you want to return to the default, pick it from `/model` (or send `/model <default provider/model>`).
|
||||
Use `/model status` to confirm which auth profile is active.
|
||||
|
||||
### Can I use GPT 5.2 for daily tasks and Codex 5.3 for coding
|
||||
### Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding
|
||||
|
||||
Yes. Set one as default and switch as needed:
|
||||
|
||||
- **Quick switch (per session):** `/model gpt-5.2` for daily tasks, `/model gpt-5.3-codex` for coding.
|
||||
- **Default + switch:** set `agents.defaults.model.primary` to `openai/gpt-5.2`, then switch to `openai-codex/gpt-5.3-codex` when coding (or the other way around).
|
||||
- **Default + switch:** set `agents.defaults.model.primary` to `openai-codex/gpt-5.3-codex`, then switch to `openai-codex/gpt-5.3-codex-codex` when coding (or the other way around).
|
||||
- **Sub-agents:** route coding tasks to sub-agents with a different default model.
|
||||
|
||||
See [Models](/concepts/models) and [Slash commands](/tools/slash-commands).
|
||||
|
||||
@@ -41,20 +41,7 @@ title: "OpenClaw"
|
||||
</Card>
|
||||
</Columns>
|
||||
|
||||
## What is OpenClaw?
|
||||
|
||||
OpenClaw is a **self-hosted gateway** that connects your favorite chat apps — WhatsApp, Telegram, Discord, iMessage, and more — to AI coding agents like Pi. You run a single Gateway process on your own machine (or a server), and it becomes the bridge between your messaging apps and an always-available AI assistant.
|
||||
|
||||
**Who is it for?** Developers and power users who want a personal AI assistant they can message from anywhere — without giving up control of their data or relying on a hosted service.
|
||||
|
||||
**What makes it different?**
|
||||
|
||||
- **Self-hosted**: runs on your hardware, your rules
|
||||
- **Multi-channel**: one Gateway serves WhatsApp, Telegram, Discord, and more simultaneously
|
||||
- **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
|
||||
- **Open source**: MIT licensed, community-driven
|
||||
|
||||
**What do you need?** Node 22+, an API key (Anthropic recommended), and 5 minutes.
|
||||
OpenClaw connects chat apps to coding agents like Pi through a single Gateway process. It powers the OpenClaw assistant and supports local or remote setups.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ It writes config/workspace on the host:
|
||||
- `~/.openclaw/`
|
||||
- `~/.openclaw/workspace`
|
||||
|
||||
Running on a VPS? See [Hetzner (Docker VPS)](/install/hetzner).
|
||||
Running on a VPS? See [Hetzner (Docker VPS)](/platforms/hetzner).
|
||||
|
||||
### Manual flow (compose)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ If you want a $0/month option and don’t mind ARM + provider-specific setup, se
|
||||
**Picking a provider:**
|
||||
|
||||
- DigitalOcean: simplest UX + predictable setup (this guide)
|
||||
- Hetzner: good price/perf (see [Hetzner guide](/install/hetzner))
|
||||
- Hetzner: good price/perf (see [Hetzner guide](/platforms/hetzner))
|
||||
- Oracle Cloud: can be $0/month, but is more finicky and ARM-only (see [Oracle guide](/platforms/oracle))
|
||||
|
||||
---
|
||||
@@ -256,7 +256,7 @@ free -h
|
||||
|
||||
## See Also
|
||||
|
||||
- [Hetzner guide](/install/hetzner) — cheaper, more powerful
|
||||
- [Hetzner guide](/platforms/hetzner) — cheaper, more powerful
|
||||
- [Docker install](/install/docker) — containerized setup
|
||||
- [Tailscale](/gateway/tailscale) — secure remote access
|
||||
- [Configuration](/gateway/configuration) — full config reference
|
||||
|
||||
@@ -26,10 +26,10 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
|
||||
## VPS & hosting
|
||||
|
||||
- VPS hub: [VPS hosting](/vps)
|
||||
- Fly.io: [Fly.io](/install/fly)
|
||||
- Hetzner (Docker): [Hetzner](/install/hetzner)
|
||||
- GCP (Compute Engine): [GCP](/install/gcp)
|
||||
- exe.dev (VM + HTTPS proxy): [exe.dev](/install/exe-dev)
|
||||
- Fly.io: [Fly.io](/platforms/fly)
|
||||
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
|
||||
- GCP (Compute Engine): [GCP](/platforms/gcp)
|
||||
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
|
||||
## Common links
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Native Linux companion apps are planned. Contributions are welcome if you want t
|
||||
4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`
|
||||
5. Open `http://127.0.0.1:18789/` and paste your token
|
||||
|
||||
Step-by-step VPS guide: [exe.dev](/install/exe-dev)
|
||||
Step-by-step VPS guide: [exe.dev](/platforms/exe-dev)
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -300,4 +300,4 @@ tar -czvf openclaw-backup.tar.gz ~/.openclaw ~/.openclaw/workspace
|
||||
- [Tailscale integration](/gateway/tailscale) — full Tailscale docs
|
||||
- [Gateway configuration](/gateway/configuration) — all config options
|
||||
- [DigitalOcean guide](/platforms/digitalocean) — if you want paid + easier signup
|
||||
- [Hetzner guide](/install/hetzner) — Docker-based alternative
|
||||
- [Hetzner guide](/platforms/hetzner) — Docker-based alternative
|
||||
|
||||
@@ -353,6 +353,6 @@ echo 'wireless-power off' | sudo tee -a /etc/network/interfaces
|
||||
|
||||
- [Linux guide](/platforms/linux) — general Linux setup
|
||||
- [DigitalOcean guide](/platforms/digitalocean) — cloud alternative
|
||||
- [Hetzner guide](/install/hetzner) — Docker setup
|
||||
- [Hetzner guide](/platforms/hetzner) — Docker setup
|
||||
- [Tailscale](/gateway/tailscale) — remote access
|
||||
- [Nodes](/nodes) — pair your laptop/phone with the Pi gateway
|
||||
|
||||
@@ -17,8 +17,6 @@ Ollama is a local LLM runtime that makes it easy to run open-source models on yo
|
||||
2. Pull a model:
|
||||
|
||||
```bash
|
||||
ollama pull gpt-oss:20b
|
||||
# or
|
||||
ollama pull llama3.3
|
||||
# or
|
||||
ollama pull qwen2.5-coder:32b
|
||||
@@ -42,7 +40,7 @@ openclaw config set models.providers.ollama.apiKey "ollama-local"
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "ollama/gpt-oss:20b" },
|
||||
model: { primary: "ollama/llama3.3" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -107,8 +105,8 @@ Use explicit config when:
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
id: "llama3.3",
|
||||
name: "Llama 3.3",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -150,8 +148,8 @@ Once configured, all your Ollama models are available:
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "ollama/gpt-oss:20b",
|
||||
fallbacks: ["ollama/llama3.3", "ollama/qwen2.5-coder:32b"],
|
||||
primary: "ollama/llama3.3",
|
||||
fallbacks: ["ollama/qwen2.5-coder:32b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -172,48 +170,6 @@ ollama pull deepseek-r1:32b
|
||||
|
||||
Ollama is free and runs locally, so all model costs are set to $0.
|
||||
|
||||
### Streaming Configuration
|
||||
|
||||
Due to a [known issue](https://github.com/badlogic/pi-mono/issues/1205) in the underlying SDK with Ollama's response format, **streaming is disabled by default** for Ollama models. This prevents corrupted responses when using tool-capable models.
|
||||
|
||||
When streaming is disabled, responses are delivered all at once (non-streaming mode), which avoids the issue where interleaved content/reasoning deltas cause garbled output.
|
||||
|
||||
#### Re-enable Streaming (Advanced)
|
||||
|
||||
If you want to re-enable streaming for Ollama (may cause issues with tool-capable models):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"ollama/gpt-oss:20b": {
|
||||
streaming: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Disable Streaming for Other Providers
|
||||
|
||||
You can also disable streaming for any provider if needed:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": {
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Context windows
|
||||
|
||||
For auto-discovered models, OpenClaw uses the context window reported by Ollama when available, otherwise it defaults to `8192`. You can override `contextWindow` and `maxTokens` in explicit provider config.
|
||||
@@ -245,8 +201,7 @@ To add models:
|
||||
|
||||
```bash
|
||||
ollama list # See what's installed
|
||||
ollama pull gpt-oss:20b # Pull a tool-capable model
|
||||
ollama pull llama3.3 # Or another model
|
||||
ollama pull llama3.3 # Pull a model
|
||||
```
|
||||
|
||||
### Connection refused
|
||||
@@ -261,15 +216,6 @@ ps aux | grep ollama
|
||||
ollama serve
|
||||
```
|
||||
|
||||
### Corrupted responses or tool names in output
|
||||
|
||||
If you see garbled responses containing tool names (like `sessions_send`, `memory_get`) or fragmented text when using Ollama models, this is due to an upstream SDK issue with streaming responses. **This is fixed by default** in the latest OpenClaw version by disabling streaming for Ollama models.
|
||||
|
||||
If you manually enabled streaming and experience this issue:
|
||||
|
||||
1. Remove the `streaming: true` configuration from your Ollama model entries, or
|
||||
2. Explicitly set `streaming: false` for Ollama models (see [Streaming Configuration](#streaming-configuration))
|
||||
|
||||
## See Also
|
||||
|
||||
- [Model Providers](/concepts/model-providers) - Overview of all providers
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
---
|
||||
summary: "Full reference for the CLI onboarding wizard: every step, flag, and config field"
|
||||
read_when:
|
||||
- Looking up a specific wizard step or flag
|
||||
- Automating onboarding with non-interactive mode
|
||||
- Debugging wizard behavior
|
||||
title: "Onboarding Wizard Reference"
|
||||
sidebarTitle: "Wizard Reference"
|
||||
---
|
||||
|
||||
# Onboarding Wizard Reference
|
||||
|
||||
This is the full reference for the `openclaw onboard` CLI wizard.
|
||||
For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
|
||||
## Flow details (local mode)
|
||||
|
||||
<Steps>
|
||||
<Step title="Existing config detection">
|
||||
- If `~/.openclaw/openclaw.json` exists, choose **Keep / Modify / Reset**.
|
||||
- Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset**
|
||||
(or pass `--reset`).
|
||||
- If the config is invalid or contains legacy keys, the wizard stops and asks
|
||||
you to run `openclaw doctor` before continuing.
|
||||
- Reset uses `trash` (never `rm`) and offers scopes:
|
||||
- Config only
|
||||
- Config + credentials + sessions
|
||||
- Full reset (also removes workspace)
|
||||
</Step>
|
||||
<Step title="Model/Auth">
|
||||
- **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
||||
- **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
||||
- **Anthropic token (paste setup-token)**: run `claude setup-token` on any machine, then paste the token (you can name it; blank = default).
|
||||
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
|
||||
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
|
||||
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.openclaw/.env` so launchd can read it.
|
||||
- **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth).
|
||||
- **API key**: stores the key for you.
|
||||
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
|
||||
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- **Cloudflare AI Gateway**: prompts for Account ID, Gateway ID, and `CLOUDFLARE_AI_GATEWAY_API_KEY`.
|
||||
- More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- **MiniMax M2.1**: config is auto-written.
|
||||
- More detail: [MiniMax](/providers/minimax)
|
||||
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
||||
- More detail: [Synthetic](/providers/synthetic)
|
||||
- **Moonshot (Kimi K2)**: config is auto-written.
|
||||
- **Kimi Coding**: config is auto-written.
|
||||
- More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
|
||||
- **Skip**: no auth configured yet.
|
||||
- Pick a default model from detected options (or enter provider/model manually).
|
||||
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||
- OAuth credentials live in `~/.openclaw/credentials/oauth.json`; auth profiles live in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (API keys + OAuth).
|
||||
- More detail: [/concepts/oauth](/concepts/oauth)
|
||||
<Note>
|
||||
Headless/server tip: complete OAuth on a machine with a browser, then copy
|
||||
`~/.openclaw/credentials/oauth.json` (or `$OPENCLAW_STATE_DIR/credentials/oauth.json`) to the
|
||||
gateway host.
|
||||
</Note>
|
||||
</Step>
|
||||
<Step title="Workspace">
|
||||
- Default `~/.openclaw/workspace` (configurable).
|
||||
- Seeds the workspace files needed for the agent bootstrap ritual.
|
||||
- Full workspace layout + backup guide: [Agent workspace](/concepts/agent-workspace)
|
||||
</Step>
|
||||
<Step title="Gateway">
|
||||
- Port, bind, auth mode, tailscale exposure.
|
||||
- Auth recommendation: keep **Token** even for loopback so local WS clients must authenticate.
|
||||
- Disable auth only if you fully trust every local process.
|
||||
- Non‑loopback binds still require auth.
|
||||
</Step>
|
||||
<Step title="Channels">
|
||||
- [WhatsApp](/channels/whatsapp): optional QR login.
|
||||
- [Telegram](/channels/telegram): bot token.
|
||||
- [Discord](/channels/discord): bot token.
|
||||
- [Google Chat](/channels/googlechat): service account JSON + webhook audience.
|
||||
- [Mattermost](/channels/mattermost) (plugin): bot token + base URL.
|
||||
- [Signal](/channels/signal): optional `signal-cli` install + account config.
|
||||
- [BlueBubbles](/channels/bluebubbles): **recommended for iMessage**; server URL + password + webhook.
|
||||
- [iMessage](/channels/imessage): legacy `imsg` CLI path + DB access.
|
||||
- DM security: default is pairing. First DM sends a code; approve via `openclaw pairing approve <channel> <code>` or use allowlists.
|
||||
</Step>
|
||||
<Step title="Daemon install">
|
||||
- macOS: LaunchAgent
|
||||
- Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped).
|
||||
- Linux (and Windows via WSL2): systemd user unit
|
||||
- Wizard attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
|
||||
- May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first.
|
||||
- **Runtime selection:** Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**.
|
||||
</Step>
|
||||
<Step title="Health check">
|
||||
- Starts the Gateway (if needed) and runs `openclaw health`.
|
||||
- Tip: `openclaw status --deep` adds gateway health probes to status output (requires a reachable gateway).
|
||||
</Step>
|
||||
<Step title="Skills (recommended)">
|
||||
- Reads the available skills and checks requirements.
|
||||
- Lets you choose a node manager: **npm / pnpm** (bun not recommended).
|
||||
- Installs optional dependencies (some use Homebrew on macOS).
|
||||
</Step>
|
||||
<Step title="Finish">
|
||||
- Summary + next steps, including iOS/Android/macOS apps for extra features.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
If no GUI is detected, the wizard prints SSH port-forward instructions for the Control UI instead of opening a browser.
|
||||
If the Control UI assets are missing, the wizard attempts to build them; fallback is `pnpm ui:build` (auto-installs UI deps).
|
||||
</Note>
|
||||
|
||||
## Non-interactive mode
|
||||
|
||||
Use `--non-interactive` to automate or script onboarding:
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice apiKey \
|
||||
--anthropic-api-key "$ANTHROPIC_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback \
|
||||
--install-daemon \
|
||||
--daemon-runtime node \
|
||||
--skip-skills
|
||||
```
|
||||
|
||||
Add `--json` for a machine‑readable summary.
|
||||
|
||||
<Note>
|
||||
`--json` does **not** imply non-interactive mode. Use `--non-interactive` (and `--workspace`) for scripts.
|
||||
</Note>
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Gemini example">
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice gemini-api-key \
|
||||
--gemini-api-key "$GEMINI_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="Z.AI example">
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice zai-api-key \
|
||||
--zai-api-key "$ZAI_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="Vercel AI Gateway example">
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice ai-gateway-api-key \
|
||||
--ai-gateway-api-key "$AI_GATEWAY_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="Cloudflare AI Gateway example">
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice cloudflare-ai-gateway-api-key \
|
||||
--cloudflare-ai-gateway-account-id "your-account-id" \
|
||||
--cloudflare-ai-gateway-gateway-id "your-gateway-id" \
|
||||
--cloudflare-ai-gateway-api-key "$CLOUDFLARE_AI_GATEWAY_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="Moonshot example">
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice moonshot-api-key \
|
||||
--moonshot-api-key "$MOONSHOT_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="Synthetic example">
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice synthetic-api-key \
|
||||
--synthetic-api-key "$SYNTHETIC_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="OpenCode Zen example">
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice opencode-zen \
|
||||
--opencode-zen-api-key "$OPENCODE_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Add agent (non-interactive)
|
||||
|
||||
```bash
|
||||
openclaw agents add work \
|
||||
--workspace ~/.openclaw/workspace-work \
|
||||
--model openai/gpt-5.2 \
|
||||
--bind whatsapp:biz \
|
||||
--non-interactive \
|
||||
--json
|
||||
```
|
||||
|
||||
## Gateway wizard RPC
|
||||
|
||||
The Gateway exposes the wizard flow over RPC (`wizard.start`, `wizard.next`, `wizard.cancel`, `wizard.status`).
|
||||
Clients (macOS app, Control UI) can render steps without re‑implementing onboarding logic.
|
||||
|
||||
## Signal setup (signal-cli)
|
||||
|
||||
The wizard can install `signal-cli` from GitHub releases:
|
||||
|
||||
- Downloads the appropriate release asset.
|
||||
- Stores it under `~/.openclaw/tools/signal-cli/<version>/`.
|
||||
- Writes `channels.signal.cliPath` to your config.
|
||||
|
||||
Notes:
|
||||
|
||||
- JVM builds require **Java 21**.
|
||||
- Native builds are used when available.
|
||||
- Windows uses WSL2; signal-cli install follows the Linux flow inside WSL.
|
||||
|
||||
## What the wizard writes
|
||||
|
||||
Typical fields in `~/.openclaw/openclaw.json`:
|
||||
|
||||
- `agents.defaults.workspace`
|
||||
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
|
||||
- `gateway.*` (mode, bind, auth, tailscale)
|
||||
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
|
||||
- Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible).
|
||||
- `skills.install.nodeManager`
|
||||
- `wizard.lastRunAt`
|
||||
- `wizard.lastRunVersion`
|
||||
- `wizard.lastRunCommit`
|
||||
- `wizard.lastRunCommand`
|
||||
- `wizard.lastRunMode`
|
||||
|
||||
`openclaw agents add` writes `agents.list[]` and optional `bindings`.
|
||||
|
||||
WhatsApp credentials go under `~/.openclaw/credentials/whatsapp/<accountId>/`.
|
||||
Sessions are stored under `~/.openclaw/agents/<agentId>/sessions/`.
|
||||
|
||||
Some channels are delivered as plugins. When you pick one during onboarding, the wizard
|
||||
will prompt to install it (npm or a local path) before it can be configured.
|
||||
|
||||
## Related docs
|
||||
|
||||
- Wizard overview: [Onboarding Wizard](/start/wizard)
|
||||
- macOS app onboarding: [Onboarding](/start/onboarding)
|
||||
- Config reference: [Gateway configuration](/gateway/configuration)
|
||||
- Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Google Chat](/channels/googlechat), [Signal](/channels/signal), [BlueBubbles](/channels/bluebubbles) (iMessage), [iMessage](/channels/imessage) (legacy)
|
||||
- Skills: [Skills](/tools/skills), [Skills config](/tools/skills-config)
|
||||
@@ -4,7 +4,7 @@ read_when:
|
||||
- Designing the macOS onboarding assistant
|
||||
- Implementing auth or identity setup
|
||||
title: "Onboarding (macOS App)"
|
||||
sidebarTitle: "Onboarding: macOS App"
|
||||
sidebarTitle: "macOS app"
|
||||
---
|
||||
|
||||
# Onboarding (macOS App)
|
||||
@@ -16,22 +16,22 @@ wizard, and let the agent bootstrap itself.
|
||||
<Steps>
|
||||
<Step title="Approve macOS warning">
|
||||
<Frame>
|
||||
<img src="/assets/macos-onboarding/01-macos-warning.jpeg" alt="" />
|
||||
<img src="/assets/macos-onboarding/01-macos-warning.jpeg" alt=""></img>
|
||||
</Frame>
|
||||
</Step>
|
||||
<Step title="Approve find local networks">
|
||||
<Frame>
|
||||
<img src="/assets/macos-onboarding/02-local-networks.jpeg" alt="" />
|
||||
<img src="/assets/macos-onboarding/02-local-networks.jpeg" alt=""></img>
|
||||
</Frame>
|
||||
</Step>
|
||||
<Step title="Welcome and security notice">
|
||||
<Frame caption="Read the security notice displayed and decide accordingly">
|
||||
<img src="/assets/macos-onboarding/03-security-notice.png" alt="" />
|
||||
<img src="/assets/macos-onboarding/03-security-notice.png" alt=""></img>
|
||||
</Frame>
|
||||
</Step>
|
||||
<Step title="Local vs Remote">
|
||||
<Frame>
|
||||
<img src="/assets/macos-onboarding/04-choose-gateway.png" alt="" />
|
||||
<img src="/assets/macos-onboarding/04-choose-gateway.png" alt=""></img>
|
||||
</Frame>
|
||||
|
||||
Where does the **Gateway** run?
|
||||
@@ -51,7 +51,7 @@ Where does the **Gateway** run?
|
||||
</Step>
|
||||
<Step title="Permissions">
|
||||
<Frame caption="Choose what permissions do you want to give OpenClaw">
|
||||
<img src="/assets/macos-onboarding/05-permissions.png" alt="" />
|
||||
<img src="/assets/macos-onboarding/05-permissions.png" alt=""></img>
|
||||
</Frame>
|
||||
|
||||
Onboarding requests TCC permissions needed for:
|
||||
|
||||
@@ -26,9 +26,26 @@ Start conservative:
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenClaw installed and onboarded — see [Getting Started](/start/getting-started) if you haven't done this yet
|
||||
- Node **22+**
|
||||
- OpenClaw available on PATH (recommended: global install)
|
||||
- A second phone number (SIM/eSIM/prepaid) for the assistant
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
# or: pnpm add -g openclaw@latest
|
||||
```
|
||||
|
||||
From source (development):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
pnpm link --global
|
||||
```
|
||||
|
||||
## The two-phone setup (recommended)
|
||||
|
||||
You want this:
|
||||
|
||||
@@ -4,15 +4,14 @@ read_when:
|
||||
- Running or configuring the onboarding wizard
|
||||
- Setting up a new machine
|
||||
title: "Onboarding Wizard (CLI)"
|
||||
sidebarTitle: "Onboarding: CLI"
|
||||
sidebarTitle: "Wizard (CLI)"
|
||||
---
|
||||
|
||||
# Onboarding Wizard (CLI)
|
||||
|
||||
The onboarding wizard is the **recommended** way to set up OpenClaw on macOS,
|
||||
Linux, or Windows (via WSL2; strongly recommended).
|
||||
It configures a local Gateway or a remote Gateway connection, plus channels, skills,
|
||||
and workspace defaults in one guided flow.
|
||||
The CLI onboarding wizard is the recommended setup path for OpenClaw on macOS,
|
||||
Linux, and Windows (via WSL2). It configures a local gateway or a remote
|
||||
gateway connection, plus workspace defaults, channels, and skills.
|
||||
|
||||
```bash
|
||||
openclaw onboard
|
||||
@@ -23,7 +22,36 @@ Fastest first chat: open the Control UI (no channel setup needed). Run
|
||||
`openclaw dashboard` and chat in the browser. Docs: [Dashboard](/web/dashboard).
|
||||
</Info>
|
||||
|
||||
To reconfigure later:
|
||||
## QuickStart vs Advanced
|
||||
|
||||
The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
|
||||
<Tabs>
|
||||
<Tab title="QuickStart (defaults)">
|
||||
- Local gateway on loopback
|
||||
- Existing workspace or default workspace
|
||||
- Gateway port `18789`
|
||||
- Gateway auth token auto-generated (even on loopback)
|
||||
- Tailscale exposure off
|
||||
- Telegram and WhatsApp DMs default to allowlist (you may be prompted for your phone number)
|
||||
</Tab>
|
||||
<Tab title="Advanced (full control)">
|
||||
- Exposes full prompt flow for mode, workspace, gateway, channels, daemon, and skills
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## CLI onboarding details
|
||||
|
||||
<Columns>
|
||||
<Card title="CLI reference" href="/start/wizard-cli-reference">
|
||||
Full local and remote flow, auth and model matrix, config outputs, wizard RPC, and signal-cli behavior.
|
||||
</Card>
|
||||
<Card title="Automation and scripts" href="/start/wizard-cli-automation">
|
||||
Non-interactive onboarding recipes and automated `agents add` examples.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
||||
## Common follow-up commands
|
||||
|
||||
```bash
|
||||
openclaw configure
|
||||
@@ -40,67 +68,6 @@ Recommended: set up a Brave Search API key so the agent can use `web_search`
|
||||
which stores `tools.web.search.apiKey`. Docs: [Web tools](/tools/web).
|
||||
</Tip>
|
||||
|
||||
## QuickStart vs Advanced
|
||||
|
||||
The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
|
||||
<Tabs>
|
||||
<Tab title="QuickStart (defaults)">
|
||||
- Local gateway (loopback)
|
||||
- Workspace default (or existing workspace)
|
||||
- Gateway port **18789**
|
||||
- Gateway auth **Token** (auto‑generated, even on loopback)
|
||||
- Tailscale exposure **Off**
|
||||
- Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number)
|
||||
</Tab>
|
||||
<Tab title="Advanced (full control)">
|
||||
- Exposes every step (mode, workspace, gateway, channels, daemon, skills).
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## What the wizard configures
|
||||
|
||||
**Local mode (default)** walks you through these steps:
|
||||
|
||||
1. **Model/Auth** — Anthropic API key (recommended), OAuth, OpenAI, or other providers. Pick a default model.
|
||||
2. **Workspace** — Location for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files.
|
||||
3. **Gateway** — Port, bind address, auth mode, Tailscale exposure.
|
||||
4. **Channels** — WhatsApp, Telegram, Discord, Google Chat, Mattermost, Signal, BlueBubbles, or iMessage.
|
||||
5. **Daemon** — Installs a LaunchAgent (macOS) or systemd user unit (Linux/WSL2).
|
||||
6. **Health check** — Starts the Gateway and verifies it's running.
|
||||
7. **Skills** — Installs recommended skills and optional dependencies.
|
||||
|
||||
<Note>
|
||||
Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`).
|
||||
If the config is invalid or contains legacy keys, the wizard asks you to run `openclaw doctor` first.
|
||||
</Note>
|
||||
|
||||
**Remote mode** only configures the local client to connect to a Gateway elsewhere.
|
||||
It does **not** install or change anything on the remote host.
|
||||
|
||||
## Add another agent
|
||||
|
||||
Use `openclaw agents add <name>` to create a separate agent with its own workspace,
|
||||
sessions, and auth profiles. Running without `--workspace` launches the wizard.
|
||||
|
||||
What it sets:
|
||||
|
||||
- `agents.list[].name`
|
||||
- `agents.list[].workspace`
|
||||
- `agents.list[].agentDir`
|
||||
|
||||
Notes:
|
||||
|
||||
- Default workspaces follow `~/.openclaw/workspace-<agentId>`.
|
||||
- Add `bindings` to route inbound messages (the wizard can do this).
|
||||
- Non-interactive flags: `--model`, `--agent-dir`, `--bind`, `--non-interactive`.
|
||||
|
||||
## Full reference
|
||||
|
||||
For detailed step-by-step breakdowns, non-interactive scripting, Signal setup,
|
||||
RPC API, and a full list of config fields the wizard writes, see the
|
||||
[Wizard Reference](/reference/wizard).
|
||||
|
||||
## Related docs
|
||||
|
||||
- CLI command reference: [`openclaw onboard`](/cli/onboard)
|
||||
|
||||
@@ -16,7 +16,6 @@ title: "Thinking Levels"
|
||||
- medium → “think harder”
|
||||
- high → “ultrathink” (max budget)
|
||||
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
|
||||
- `x-high`, `x_high`, `extra-high`, `extra high`, and `extra_high` map to `xhigh`.
|
||||
- `highest`, `max` map to `high`.
|
||||
- Provider notes:
|
||||
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
|
||||
|
||||
12
docs/vps.md
12
docs/vps.md
@@ -13,13 +13,13 @@ deployments work at a high level.
|
||||
|
||||
## Pick a provider
|
||||
|
||||
- **Railway** (one‑click + browser setup): [Railway](/install/railway)
|
||||
- **Northflank** (one‑click + browser setup): [Northflank](/install/northflank)
|
||||
- **Railway** (one‑click + browser setup): [Railway](/railway)
|
||||
- **Northflank** (one‑click + browser setup): [Northflank](/northflank)
|
||||
- **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky)
|
||||
- **Fly.io**: [Fly.io](/install/fly)
|
||||
- **Hetzner (Docker)**: [Hetzner](/install/hetzner)
|
||||
- **GCP (Compute Engine)**: [GCP](/install/gcp)
|
||||
- **exe.dev** (VM + HTTPS proxy): [exe.dev](/install/exe-dev)
|
||||
- **Fly.io**: [Fly.io](/platforms/fly)
|
||||
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
|
||||
- **GCP (Compute Engine)**: [GCP](/platforms/gcp)
|
||||
- **exe.dev** (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
- **AWS (EC2/Lightsail/free tier)**: works well too. Video guide:
|
||||
https://x.com/techfrenAJ/status/2014934471095812547
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ x-i18n:
|
||||
|
||||
- **最佳用户体验:** 保持 `gateway.bind: "loopback"` 并使用 **Tailscale Serve** 作为控制 UI。
|
||||
- **回退方案:** 保持 loopback + 从任何需要访问的机器建立 SSH 隧道。
|
||||
- **示例:** [exe.dev](/install/exe-dev)(简易 VM)或 [Hetzner](/install/hetzner)(生产 VPS)。
|
||||
- **示例:** [exe.dev](/platforms/exe-dev)(简易 VM)或 [Hetzner](/platforms/hetzner)(生产 VPS)。
|
||||
|
||||
当你的笔记本电脑经常休眠但你希望智能体始终在线时,这是理想的选择。
|
||||
|
||||
|
||||
@@ -572,7 +572,7 @@ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method git
|
||||
|
||||
任何 Linux VPS 都可以。在服务器上安装,然后使用 SSH/Tailscale 访问 Gateway 网关。
|
||||
|
||||
指南:[exe.dev](/install/exe-dev)、[Hetzner](/install/hetzner)、[Fly.io](/install/fly)。
|
||||
指南:[exe.dev](/platforms/exe-dev)、[Hetzner](/platforms/hetzner)、[Fly.io](/platforms/fly)。
|
||||
远程访问:[Gateway 网关远程](/gateway/remote)。
|
||||
|
||||
### 云/VPS 安装指南在哪里
|
||||
@@ -580,9 +580,9 @@ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method git
|
||||
我们维护了一个**托管中心**,涵盖常见提供商。选择一个并按指南操作:
|
||||
|
||||
- [VPS 托管](/vps)(所有提供商汇总)
|
||||
- [Fly.io](/install/fly)
|
||||
- [Hetzner](/install/hetzner)
|
||||
- [exe.dev](/install/exe-dev)
|
||||
- [Fly.io](/platforms/fly)
|
||||
- [Hetzner](/platforms/hetzner)
|
||||
- [exe.dev](/platforms/exe-dev)
|
||||
|
||||
在云端的工作方式:**Gateway 网关运行在服务器上**,你通过控制 UI(或 Tailscale/SSH)从笔记本/手机访问。你的状态 + 工作区位于服务器上,因此将主机视为数据来源并做好备份。
|
||||
|
||||
@@ -863,7 +863,7 @@ OpenClaw 是轻量级的。对于基本的 Gateway 网关 + 一个聊天渠道
|
||||
- **操作系统:** Ubuntu LTS 或其他现代 Debian/Ubuntu。
|
||||
|
||||
如果你使用 Windows,**WSL2 是最简单的虚拟机式设置**,具有最佳的工具兼容性。参阅 [Windows](/platforms/windows)、[VPS 托管](/vps)。
|
||||
如果你在虚拟机中运行 macOS,参阅 [macOS VM](/install/macos-vm)。
|
||||
如果你在虚拟机中运行 macOS,参阅 [macOS VM](/platforms/macos-vm)。
|
||||
|
||||
## 什么是 OpenClaw?
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ Docker 是**可选的**。仅当你想要容器化的 Gateway 网关或验证 Do
|
||||
- `~/.openclaw/`
|
||||
- `~/.openclaw/workspace`
|
||||
|
||||
在 VPS 上运行?参阅 [Hetzner(Docker VPS)](/install/hetzner)。
|
||||
在 VPS 上运行?参阅 [Hetzner(Docker VPS)](/platforms/hetzner)。
|
||||
|
||||
### 手动流程(compose)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ x-i18n:
|
||||
**选择提供商:**
|
||||
|
||||
- DigitalOcean:最简单的用户体验 + 可预测的设置(本指南)
|
||||
- Hetzner:性价比高(参见 [Hetzner 指南](/install/hetzner))
|
||||
- Hetzner:性价比高(参见 [Hetzner 指南](/platforms/hetzner))
|
||||
- Oracle Cloud:可以 $0/月,但更麻烦且仅限 ARM(参见 [Oracle 指南](/platforms/oracle))
|
||||
|
||||
---
|
||||
@@ -263,7 +263,7 @@ free -h
|
||||
|
||||
## 另请参阅
|
||||
|
||||
- [Hetzner 指南](/install/hetzner) — 更便宜、更强大
|
||||
- [Hetzner 指南](/platforms/hetzner) — 更便宜、更强大
|
||||
- [Docker 安装](/install/docker) — 容器化设置
|
||||
- [Tailscale](/gateway/tailscale) — 安全远程访问
|
||||
- [配置](/gateway/configuration) — 完整配置参考
|
||||
|
||||
@@ -33,10 +33,10 @@ Windows 原生配套应用也在计划中;推荐通过 WSL2 使用 Gateway 网
|
||||
## VPS 和托管
|
||||
|
||||
- VPS 中心:[VPS 托管](/vps)
|
||||
- Fly.io:[Fly.io](/install/fly)
|
||||
- Hetzner(Docker):[Hetzner](/install/hetzner)
|
||||
- GCP(Compute Engine):[GCP](/install/gcp)
|
||||
- exe.dev(VM + HTTPS 代理):[exe.dev](/install/exe-dev)
|
||||
- Fly.io:[Fly.io](/platforms/fly)
|
||||
- Hetzner(Docker):[Hetzner](/platforms/hetzner)
|
||||
- GCP(Compute Engine):[GCP](/platforms/gcp)
|
||||
- exe.dev(VM + HTTPS 代理):[exe.dev](/platforms/exe-dev)
|
||||
|
||||
## 常用链接
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Gateway 网关在 Linux 上完全支持。**Node 是推荐的运行时**。
|
||||
4. 从你的笔记本电脑:`ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`
|
||||
5. 打开 `http://127.0.0.1:18789/` 并粘贴你的令牌
|
||||
|
||||
分步 VPS 指南:[exe.dev](/install/exe-dev)
|
||||
分步 VPS 指南:[exe.dev](/platforms/exe-dev)
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
@@ -307,4 +307,4 @@ tar -czvf openclaw-backup.tar.gz ~/.openclaw ~/.openclaw/workspace
|
||||
- [Tailscale 集成](/gateway/tailscale) — 完整的 Tailscale 文档
|
||||
- [Gateway 网关配置](/gateway/configuration) — 所有配置选项
|
||||
- [DigitalOcean 指南](/platforms/digitalocean) — 如果你想要付费 + 更容易注册
|
||||
- [Hetzner 指南](/install/hetzner) — 基于 Docker 的替代方案
|
||||
- [Hetzner 指南](/platforms/hetzner) — 基于 Docker 的替代方案
|
||||
|
||||
@@ -360,6 +360,6 @@ echo 'wireless-power off' | sudo tee -a /etc/network/interfaces
|
||||
|
||||
- [Linux 指南](/platforms/linux) — 通用 Linux 设置
|
||||
- [DigitalOcean 指南](/platforms/digitalocean) — 云替代方案
|
||||
- [Hetzner 指南](/install/hetzner) — Docker 设置
|
||||
- [Hetzner 指南](/platforms/hetzner) — Docker 设置
|
||||
- [Tailscale](/gateway/tailscale) — 远程访问
|
||||
- [节点](/nodes) — 将你的笔记本电脑/手机与 Pi Gateway 网关配对
|
||||
|
||||
@@ -203,4 +203,4 @@ openclaw message send --target +15555550123 --message "Hello from OpenClaw"
|
||||
- macOS 菜单栏应用 + 语音唤醒:[macOS 应用](/platforms/macos)
|
||||
- iOS/Android 节点(Canvas/相机/语音):[节点](/nodes)
|
||||
- 远程访问(SSH 隧道 / Tailscale Serve):[远程访问](/gateway/remote) 和 [Tailscale](/gateway/tailscale)
|
||||
- 常开 / VPN 设置:[远程访问](/gateway/remote)、[exe.dev](/install/exe-dev)、[Hetzner](/install/hetzner)、[macOS 远程](/platforms/mac/remote)
|
||||
- 常开 / VPN 设置:[远程访问](/gateway/remote)、[exe.dev](/platforms/exe-dev)、[Hetzner](/platforms/hetzner)、[macOS 远程](/platforms/mac/remote)
|
||||
|
||||
@@ -22,10 +22,10 @@ x-i18n:
|
||||
- **Railway**(一键 + 浏览器设置):[Railway](/railway)
|
||||
- **Northflank**(一键 + 浏览器设置):[Northflank](/northflank)
|
||||
- **Oracle Cloud(永久免费)**:[Oracle](/platforms/oracle) — $0/月(永久免费,ARM;容量/注册可能不太稳定)
|
||||
- **Fly.io**:[Fly.io](/install/fly)
|
||||
- **Hetzner(Docker)**:[Hetzner](/install/hetzner)
|
||||
- **GCP(Compute Engine)**:[GCP](/install/gcp)
|
||||
- **exe.dev**(VM + HTTPS 代理):[exe.dev](/install/exe-dev)
|
||||
- **Fly.io**:[Fly.io](/platforms/fly)
|
||||
- **Hetzner(Docker)**:[Hetzner](/platforms/hetzner)
|
||||
- **GCP(Compute Engine)**:[GCP](/platforms/gcp)
|
||||
- **exe.dev**(VM + HTTPS 代理):[exe.dev](/platforms/exe-dev)
|
||||
- **AWS(EC2/Lightsail/免费套餐)**:也运行良好。视频指南:
|
||||
https://x.com/techfrenAJ/status/2014934471095812547
|
||||
|
||||
|
||||
47
extensions/feishu/README.md
Normal file
47
extensions/feishu/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# @openclaw/feishu
|
||||
|
||||
Feishu/Lark channel plugin for OpenClaw (WebSocket bot events).
|
||||
|
||||
## Install (local checkout)
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./extensions/feishu
|
||||
```
|
||||
|
||||
## Install (npm)
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/feishu
|
||||
```
|
||||
|
||||
Onboarding: select Feishu/Lark and confirm the install prompt to fetch the plugin automatically.
|
||||
|
||||
## Config
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
default: {
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
domain: "feishu",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "open",
|
||||
blockStreaming: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Lark (global) tenants should set `domain: "lark"` (or a full https:// domain).
|
||||
|
||||
Restart the gateway after config changes.
|
||||
|
||||
## Docs
|
||||
|
||||
https://docs.openclaw.ai/channels/feishu
|
||||
@@ -1,62 +1,14 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { registerFeishuBitableTools } from "./src/bitable.js";
|
||||
import { feishuPlugin } from "./src/channel.js";
|
||||
import { registerFeishuDocTools } from "./src/docx.js";
|
||||
import { registerFeishuDriveTools } from "./src/drive.js";
|
||||
import { registerFeishuPermTools } from "./src/perm.js";
|
||||
import { setFeishuRuntime } from "./src/runtime.js";
|
||||
import { registerFeishuWikiTools } from "./src/wiki.js";
|
||||
|
||||
export { monitorFeishuProvider } from "./src/monitor.js";
|
||||
export {
|
||||
sendMessageFeishu,
|
||||
sendCardFeishu,
|
||||
updateCardFeishu,
|
||||
editMessageFeishu,
|
||||
getMessageFeishu,
|
||||
} from "./src/send.js";
|
||||
export {
|
||||
uploadImageFeishu,
|
||||
uploadFileFeishu,
|
||||
sendImageFeishu,
|
||||
sendFileFeishu,
|
||||
sendMediaFeishu,
|
||||
} from "./src/media.js";
|
||||
export { probeFeishu } from "./src/probe.js";
|
||||
export {
|
||||
addReactionFeishu,
|
||||
removeReactionFeishu,
|
||||
listReactionsFeishu,
|
||||
FeishuEmoji,
|
||||
} from "./src/reactions.js";
|
||||
export {
|
||||
extractMentionTargets,
|
||||
extractMessageBody,
|
||||
isMentionForwardRequest,
|
||||
formatMentionForText,
|
||||
formatMentionForCard,
|
||||
formatMentionAllForText,
|
||||
formatMentionAllForCard,
|
||||
buildMentionedMessage,
|
||||
buildMentionedCardContent,
|
||||
type MentionTarget,
|
||||
} from "./src/mention.js";
|
||||
export { feishuPlugin } from "./src/channel.js";
|
||||
|
||||
const plugin = {
|
||||
id: "feishu",
|
||||
name: "Feishu",
|
||||
description: "Feishu/Lark channel plugin",
|
||||
description: "Feishu (Lark) channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
setFeishuRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: feishuPlugin });
|
||||
registerFeishuDocTools(api);
|
||||
registerFeishuWikiTools(api);
|
||||
registerFeishuDriveTools(api);
|
||||
registerFeishuPermTools(api);
|
||||
registerFeishuBitableTools(api);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"id": "feishu",
|
||||
"channels": ["feishu"],
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.2.4",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"description": "OpenClaw Feishu channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.56.1",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
@@ -18,10 +13,11 @@
|
||||
"channel": {
|
||||
"id": "feishu",
|
||||
"label": "Feishu",
|
||||
"selectionLabel": "Feishu/Lark (飞书)",
|
||||
"selectionLabel": "Feishu (Lark Open Platform)",
|
||||
"detailLabel": "Feishu Bot",
|
||||
"docsPath": "/channels/feishu",
|
||||
"docsLabel": "feishu",
|
||||
"blurb": "飞书/Lark enterprise messaging with doc/wiki/drive tools.",
|
||||
"blurb": "Feishu/Lark bot via WebSocket.",
|
||||
"aliases": [
|
||||
"lark"
|
||||
],
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
name: feishu-doc
|
||||
description: |
|
||||
Feishu document read/write operations. Activate when user mentions Feishu docs, cloud docs, or docx links.
|
||||
---
|
||||
|
||||
# Feishu Document Tool
|
||||
|
||||
Single tool `feishu_doc` with action parameter for all document operations.
|
||||
|
||||
## Token Extraction
|
||||
|
||||
From URL `https://xxx.feishu.cn/docx/ABC123def` → `doc_token` = `ABC123def`
|
||||
|
||||
## Actions
|
||||
|
||||
### Read Document
|
||||
|
||||
```json
|
||||
{ "action": "read", "doc_token": "ABC123def" }
|
||||
```
|
||||
|
||||
Returns: title, plain text content, block statistics. Check `hint` field - if present, structured content (tables, images) exists that requires `list_blocks`.
|
||||
|
||||
### Write Document (Replace All)
|
||||
|
||||
```json
|
||||
{ "action": "write", "doc_token": "ABC123def", "content": "# Title\n\nMarkdown content..." }
|
||||
```
|
||||
|
||||
Replaces entire document with markdown content. Supports: headings, lists, code blocks, quotes, links, images (`` auto-uploaded), bold/italic/strikethrough.
|
||||
|
||||
**Limitation:** Markdown tables are NOT supported.
|
||||
|
||||
### Append Content
|
||||
|
||||
```json
|
||||
{ "action": "append", "doc_token": "ABC123def", "content": "Additional content" }
|
||||
```
|
||||
|
||||
Appends markdown to end of document.
|
||||
|
||||
### Create Document
|
||||
|
||||
```json
|
||||
{ "action": "create", "title": "New Document" }
|
||||
```
|
||||
|
||||
With folder:
|
||||
|
||||
```json
|
||||
{ "action": "create", "title": "New Document", "folder_token": "fldcnXXX" }
|
||||
```
|
||||
|
||||
### List Blocks
|
||||
|
||||
```json
|
||||
{ "action": "list_blocks", "doc_token": "ABC123def" }
|
||||
```
|
||||
|
||||
Returns full block data including tables, images. Use this to read structured content.
|
||||
|
||||
### Get Single Block
|
||||
|
||||
```json
|
||||
{ "action": "get_block", "doc_token": "ABC123def", "block_id": "doxcnXXX" }
|
||||
```
|
||||
|
||||
### Update Block Text
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "update_block",
|
||||
"doc_token": "ABC123def",
|
||||
"block_id": "doxcnXXX",
|
||||
"content": "New text"
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Block
|
||||
|
||||
```json
|
||||
{ "action": "delete_block", "doc_token": "ABC123def", "block_id": "doxcnXXX" }
|
||||
```
|
||||
|
||||
## Reading Workflow
|
||||
|
||||
1. Start with `action: "read"` - get plain text + statistics
|
||||
2. Check `block_types` in response for Table, Image, Code, etc.
|
||||
3. If structured content exists, use `action: "list_blocks"` for full data
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
feishu:
|
||||
tools:
|
||||
doc: true # default: true
|
||||
```
|
||||
|
||||
**Note:** `feishu_wiki` depends on this tool - wiki page content is read/written via `feishu_doc`.
|
||||
|
||||
## Permissions
|
||||
|
||||
Required: `docx:document`, `docx:document:readonly`, `docx:document.block:convert`, `drive:drive`
|
||||
@@ -1,103 +0,0 @@
|
||||
# Feishu Block Types Reference
|
||||
|
||||
Complete reference for Feishu document block types. Use with `feishu_doc_list_blocks`, `feishu_doc_update_block`, and `feishu_doc_delete_block`.
|
||||
|
||||
## Block Type Table
|
||||
|
||||
| block_type | Name | Description | Editable |
|
||||
| ---------- | --------------- | ------------------------------ | -------- |
|
||||
| 1 | Page | Document root (contains title) | No |
|
||||
| 2 | Text | Plain text paragraph | Yes |
|
||||
| 3 | Heading1 | H1 heading | Yes |
|
||||
| 4 | Heading2 | H2 heading | Yes |
|
||||
| 5 | Heading3 | H3 heading | Yes |
|
||||
| 6 | Heading4 | H4 heading | Yes |
|
||||
| 7 | Heading5 | H5 heading | Yes |
|
||||
| 8 | Heading6 | H6 heading | Yes |
|
||||
| 9 | Heading7 | H7 heading | Yes |
|
||||
| 10 | Heading8 | H8 heading | Yes |
|
||||
| 11 | Heading9 | H9 heading | Yes |
|
||||
| 12 | Bullet | Unordered list item | Yes |
|
||||
| 13 | Ordered | Ordered list item | Yes |
|
||||
| 14 | Code | Code block | Yes |
|
||||
| 15 | Quote | Blockquote | Yes |
|
||||
| 16 | Equation | LaTeX equation | Partial |
|
||||
| 17 | Todo | Checkbox / task item | Yes |
|
||||
| 18 | Bitable | Multi-dimensional table | No |
|
||||
| 19 | Callout | Highlight block | Yes |
|
||||
| 20 | ChatCard | Chat card embed | No |
|
||||
| 21 | Diagram | Diagram embed | No |
|
||||
| 22 | Divider | Horizontal rule | No |
|
||||
| 23 | File | File attachment | No |
|
||||
| 24 | Grid | Grid layout container | No |
|
||||
| 25 | GridColumn | Grid column | No |
|
||||
| 26 | Iframe | Embedded iframe | No |
|
||||
| 27 | Image | Image | Partial |
|
||||
| 28 | ISV | Third-party widget | No |
|
||||
| 29 | MindnoteBlock | Mindmap embed | No |
|
||||
| 30 | Sheet | Spreadsheet embed | No |
|
||||
| 31 | Table | Table | Partial |
|
||||
| 32 | TableCell | Table cell | Yes |
|
||||
| 33 | View | View embed | No |
|
||||
| 34 | Undefined | Unknown type | No |
|
||||
| 35 | QuoteContainer | Quote container | No |
|
||||
| 36 | Task | Lark Tasks integration | No |
|
||||
| 37 | OKR | OKR integration | No |
|
||||
| 38 | OKRObjective | OKR objective | No |
|
||||
| 39 | OKRKeyResult | OKR key result | No |
|
||||
| 40 | OKRProgress | OKR progress | No |
|
||||
| 41 | AddOns | Add-ons block | No |
|
||||
| 42 | JiraIssue | Jira issue embed | No |
|
||||
| 43 | WikiCatalog | Wiki catalog | No |
|
||||
| 44 | Board | Board embed | No |
|
||||
| 45 | Agenda | Agenda block | No |
|
||||
| 46 | AgendaItem | Agenda item | No |
|
||||
| 47 | AgendaItemTitle | Agenda item title | No |
|
||||
| 48 | SyncedBlock | Synced block reference | No |
|
||||
|
||||
## Editing Guidelines
|
||||
|
||||
### Text-based blocks (2-17, 19)
|
||||
|
||||
Update text content using `feishu_doc_update_block`:
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_token": "ABC123",
|
||||
"block_id": "block_xxx",
|
||||
"content": "New text content"
|
||||
}
|
||||
```
|
||||
|
||||
### Image blocks (27)
|
||||
|
||||
Images cannot be updated directly via `update_block`. Use `feishu_doc_write` or `feishu_doc_append` with markdown to add new images.
|
||||
|
||||
### Table blocks (31)
|
||||
|
||||
**Important:** Table blocks CANNOT be created via the `documentBlockChildren.create` API (error 1770029). This affects `feishu_doc_write` and `feishu_doc_append` - markdown tables will be skipped with a warning.
|
||||
|
||||
Tables can only be read (via `list_blocks`) and individual cells (type 32) can be updated, but new tables cannot be inserted programmatically via markdown.
|
||||
|
||||
### Container blocks (24, 25, 35)
|
||||
|
||||
Grid and QuoteContainer are layout containers. Edit their child blocks instead.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Replace specific paragraph
|
||||
|
||||
1. `feishu_doc_list_blocks` - find the block_id
|
||||
2. `feishu_doc_update_block` - update its content
|
||||
|
||||
### Insert content at specific location
|
||||
|
||||
Currently, the API only supports appending to document end. For insertion at specific positions, consider:
|
||||
|
||||
1. Read existing content
|
||||
2. Delete affected blocks
|
||||
3. Rewrite with new content in desired order
|
||||
|
||||
### Delete multiple blocks
|
||||
|
||||
Blocks must be deleted one at a time. Delete child blocks before parent containers.
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
name: feishu-drive
|
||||
description: |
|
||||
Feishu cloud storage file management. Activate when user mentions cloud space, folders, drive.
|
||||
---
|
||||
|
||||
# Feishu Drive Tool
|
||||
|
||||
Single tool `feishu_drive` for cloud storage operations.
|
||||
|
||||
## Token Extraction
|
||||
|
||||
From URL `https://xxx.feishu.cn/drive/folder/ABC123` → `folder_token` = `ABC123`
|
||||
|
||||
## Actions
|
||||
|
||||
### List Folder Contents
|
||||
|
||||
```json
|
||||
{ "action": "list" }
|
||||
```
|
||||
|
||||
Root directory (no folder_token).
|
||||
|
||||
```json
|
||||
{ "action": "list", "folder_token": "fldcnXXX" }
|
||||
```
|
||||
|
||||
Returns: files with token, name, type, url, timestamps.
|
||||
|
||||
### Get File Info
|
||||
|
||||
```json
|
||||
{ "action": "info", "file_token": "ABC123", "type": "docx" }
|
||||
```
|
||||
|
||||
Searches for the file in the root directory. Note: file must be in root or use `list` to browse folders first.
|
||||
|
||||
`type`: `doc`, `docx`, `sheet`, `bitable`, `folder`, `file`, `mindnote`, `shortcut`
|
||||
|
||||
### Create Folder
|
||||
|
||||
```json
|
||||
{ "action": "create_folder", "name": "New Folder" }
|
||||
```
|
||||
|
||||
In parent folder:
|
||||
|
||||
```json
|
||||
{ "action": "create_folder", "name": "New Folder", "folder_token": "fldcnXXX" }
|
||||
```
|
||||
|
||||
### Move File
|
||||
|
||||
```json
|
||||
{ "action": "move", "file_token": "ABC123", "type": "docx", "folder_token": "fldcnXXX" }
|
||||
```
|
||||
|
||||
### Delete File
|
||||
|
||||
```json
|
||||
{ "action": "delete", "file_token": "ABC123", "type": "docx" }
|
||||
```
|
||||
|
||||
## File Types
|
||||
|
||||
| Type | Description |
|
||||
| ---------- | ----------------------- |
|
||||
| `doc` | Old format document |
|
||||
| `docx` | New format document |
|
||||
| `sheet` | Spreadsheet |
|
||||
| `bitable` | Multi-dimensional table |
|
||||
| `folder` | Folder |
|
||||
| `file` | Uploaded file |
|
||||
| `mindnote` | Mind map |
|
||||
| `shortcut` | Shortcut |
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
feishu:
|
||||
tools:
|
||||
drive: true # default: true
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
- `drive:drive` - Full access (create, move, delete)
|
||||
- `drive:drive:readonly` - Read only (list, info)
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Bots have no root folder**: Feishu bots use `tenant_access_token` and don't have their own "My Space". The root folder concept only exists for user accounts. This means:
|
||||
- `create_folder` without `folder_token` will fail (400 error)
|
||||
- Bot can only access files/folders that have been **shared with it**
|
||||
- **Workaround**: User must first create a folder manually and share it with the bot, then bot can create subfolders inside it
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
name: feishu-perm
|
||||
description: |
|
||||
Feishu permission management for documents and files. Activate when user mentions sharing, permissions, collaborators.
|
||||
---
|
||||
|
||||
# Feishu Permission Tool
|
||||
|
||||
Single tool `feishu_perm` for managing file/document permissions.
|
||||
|
||||
## Actions
|
||||
|
||||
### List Collaborators
|
||||
|
||||
```json
|
||||
{ "action": "list", "token": "ABC123", "type": "docx" }
|
||||
```
|
||||
|
||||
Returns: members with member_type, member_id, perm, name.
|
||||
|
||||
### Add Collaborator
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"token": "ABC123",
|
||||
"type": "docx",
|
||||
"member_type": "email",
|
||||
"member_id": "user@example.com",
|
||||
"perm": "edit"
|
||||
}
|
||||
```
|
||||
|
||||
### Remove Collaborator
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "remove",
|
||||
"token": "ABC123",
|
||||
"type": "docx",
|
||||
"member_type": "email",
|
||||
"member_id": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
## Token Types
|
||||
|
||||
| Type | Description |
|
||||
| ---------- | ----------------------- |
|
||||
| `doc` | Old format document |
|
||||
| `docx` | New format document |
|
||||
| `sheet` | Spreadsheet |
|
||||
| `bitable` | Multi-dimensional table |
|
||||
| `folder` | Folder |
|
||||
| `file` | Uploaded file |
|
||||
| `wiki` | Wiki node |
|
||||
| `mindnote` | Mind map |
|
||||
|
||||
## Member Types
|
||||
|
||||
| Type | Description |
|
||||
| ------------------ | ------------------ |
|
||||
| `email` | Email address |
|
||||
| `openid` | User open_id |
|
||||
| `userid` | User user_id |
|
||||
| `unionid` | User union_id |
|
||||
| `openchat` | Group chat open_id |
|
||||
| `opendepartmentid` | Department open_id |
|
||||
|
||||
## Permission Levels
|
||||
|
||||
| Perm | Description |
|
||||
| ------------- | ------------------------------------ |
|
||||
| `view` | View only |
|
||||
| `edit` | Can edit |
|
||||
| `full_access` | Full access (can manage permissions) |
|
||||
|
||||
## Examples
|
||||
|
||||
Share document with email:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"token": "doxcnXXX",
|
||||
"type": "docx",
|
||||
"member_type": "email",
|
||||
"member_id": "alice@company.com",
|
||||
"perm": "edit"
|
||||
}
|
||||
```
|
||||
|
||||
Share folder with group:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"token": "fldcnXXX",
|
||||
"type": "folder",
|
||||
"member_type": "openchat",
|
||||
"member_id": "oc_xxx",
|
||||
"perm": "view"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
feishu:
|
||||
tools:
|
||||
perm: true # default: false (disabled)
|
||||
```
|
||||
|
||||
**Note:** This tool is disabled by default because permission management is a sensitive operation. Enable explicitly if needed.
|
||||
|
||||
## Permissions
|
||||
|
||||
Required: `drive:permission`
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
name: feishu-wiki
|
||||
description: |
|
||||
Feishu knowledge base navigation. Activate when user mentions knowledge base, wiki, or wiki links.
|
||||
---
|
||||
|
||||
# Feishu Wiki Tool
|
||||
|
||||
Single tool `feishu_wiki` for knowledge base operations.
|
||||
|
||||
## Token Extraction
|
||||
|
||||
From URL `https://xxx.feishu.cn/wiki/ABC123def` → `token` = `ABC123def`
|
||||
|
||||
## Actions
|
||||
|
||||
### List Knowledge Spaces
|
||||
|
||||
```json
|
||||
{ "action": "spaces" }
|
||||
```
|
||||
|
||||
Returns all accessible wiki spaces.
|
||||
|
||||
### List Nodes
|
||||
|
||||
```json
|
||||
{ "action": "nodes", "space_id": "7xxx" }
|
||||
```
|
||||
|
||||
With parent:
|
||||
|
||||
```json
|
||||
{ "action": "nodes", "space_id": "7xxx", "parent_node_token": "wikcnXXX" }
|
||||
```
|
||||
|
||||
### Get Node Details
|
||||
|
||||
```json
|
||||
{ "action": "get", "token": "ABC123def" }
|
||||
```
|
||||
|
||||
Returns: `node_token`, `obj_token`, `obj_type`, etc. Use `obj_token` with `feishu_doc` to read/write the document.
|
||||
|
||||
### Create Node
|
||||
|
||||
```json
|
||||
{ "action": "create", "space_id": "7xxx", "title": "New Page" }
|
||||
```
|
||||
|
||||
With type and parent:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"space_id": "7xxx",
|
||||
"title": "Sheet",
|
||||
"obj_type": "sheet",
|
||||
"parent_node_token": "wikcnXXX"
|
||||
}
|
||||
```
|
||||
|
||||
`obj_type`: `docx` (default), `sheet`, `bitable`, `mindnote`, `file`, `doc`, `slides`
|
||||
|
||||
### Move Node
|
||||
|
||||
```json
|
||||
{ "action": "move", "space_id": "7xxx", "node_token": "wikcnXXX" }
|
||||
```
|
||||
|
||||
To different location:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "move",
|
||||
"space_id": "7xxx",
|
||||
"node_token": "wikcnXXX",
|
||||
"target_space_id": "7yyy",
|
||||
"target_parent_token": "wikcnYYY"
|
||||
}
|
||||
```
|
||||
|
||||
### Rename Node
|
||||
|
||||
```json
|
||||
{ "action": "rename", "space_id": "7xxx", "node_token": "wikcnXXX", "title": "New Title" }
|
||||
```
|
||||
|
||||
## Wiki-Doc Workflow
|
||||
|
||||
To edit a wiki page:
|
||||
|
||||
1. Get node: `{ "action": "get", "token": "wiki_token" }` → returns `obj_token`
|
||||
2. Read doc: `feishu_doc { "action": "read", "doc_token": "obj_token" }`
|
||||
3. Write doc: `feishu_doc { "action": "write", "doc_token": "obj_token", "content": "..." }`
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
feishu:
|
||||
tools:
|
||||
wiki: true # default: true
|
||||
doc: true # required - wiki content uses feishu_doc
|
||||
```
|
||||
|
||||
**Dependency:** This tool requires `feishu_doc` to be enabled. Wiki pages are documents - use `feishu_wiki` to navigate, then `feishu_doc` to read/edit content.
|
||||
|
||||
## Permissions
|
||||
|
||||
Required: `wiki:wiki` or `wiki:wiki:readonly`
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
||||
import type {
|
||||
FeishuConfig,
|
||||
FeishuAccountConfig,
|
||||
FeishuDomain,
|
||||
ResolvedFeishuAccount,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* List all configured account IDs from the accounts field.
|
||||
*/
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Feishu account IDs.
|
||||
* If no accounts are configured, returns [DEFAULT_ACCOUNT_ID] for backward compatibility.
|
||||
*/
|
||||
export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) {
|
||||
// Backward compatibility: no accounts configured, use default
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return [...ids].toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default account ID.
|
||||
*/
|
||||
export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
|
||||
const ids = listFeishuAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw account-specific config.
|
||||
*/
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): FeishuAccountConfig | undefined {
|
||||
const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return accounts[accountId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge top-level config with account-specific config.
|
||||
* Account-specific fields override top-level fields.
|
||||
*/
|
||||
function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuConfig {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
|
||||
// Extract base config (exclude accounts field to avoid recursion)
|
||||
const { accounts: _ignored, ...base } = feishuCfg ?? {};
|
||||
|
||||
// Get account-specific overrides
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
|
||||
// Merge: account config overrides base config
|
||||
return { ...base, ...account } as FeishuConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Feishu credentials from a config.
|
||||
*/
|
||||
export function resolveFeishuCredentials(cfg?: FeishuConfig): {
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
encryptKey?: string;
|
||||
verificationToken?: string;
|
||||
domain: FeishuDomain;
|
||||
} | null {
|
||||
const appId = cfg?.appId?.trim();
|
||||
const appSecret = cfg?.appSecret?.trim();
|
||||
if (!appId || !appSecret) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
appId,
|
||||
appSecret,
|
||||
encryptKey: cfg?.encryptKey?.trim() || undefined,
|
||||
verificationToken: cfg?.verificationToken?.trim() || undefined,
|
||||
domain: cfg?.domain ?? "feishu",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a complete Feishu account with merged config.
|
||||
*/
|
||||
export function resolveFeishuAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedFeishuAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
|
||||
// Base enabled state (top-level)
|
||||
const baseEnabled = feishuCfg?.enabled !== false;
|
||||
|
||||
// Merge configs
|
||||
const merged = mergeFeishuAccountConfig(params.cfg, accountId);
|
||||
|
||||
// Account-level enabled state
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
|
||||
// Resolve credentials from merged config
|
||||
const creds = resolveFeishuCredentials(merged);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
configured: Boolean(creds),
|
||||
name: (merged as FeishuAccountConfig).name?.trim() || undefined,
|
||||
appId: creds?.appId,
|
||||
appSecret: creds?.appSecret,
|
||||
encryptKey: creds?.encryptKey,
|
||||
verificationToken: creds?.verificationToken,
|
||||
domain: creds?.domain ?? "feishu",
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all enabled and configured accounts.
|
||||
*/
|
||||
export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] {
|
||||
return listFeishuAccountIds(cfg)
|
||||
.map((accountId) => resolveFeishuAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled && account.configured);
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
function json(data: unknown) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
/** Field type ID to human-readable name */
|
||||
const FIELD_TYPE_NAMES: Record<number, string> = {
|
||||
1: "Text",
|
||||
2: "Number",
|
||||
3: "SingleSelect",
|
||||
4: "MultiSelect",
|
||||
5: "DateTime",
|
||||
7: "Checkbox",
|
||||
11: "User",
|
||||
13: "Phone",
|
||||
15: "URL",
|
||||
17: "Attachment",
|
||||
18: "SingleLink",
|
||||
19: "Lookup",
|
||||
20: "Formula",
|
||||
21: "DuplexLink",
|
||||
22: "Location",
|
||||
23: "GroupChat",
|
||||
1001: "CreatedTime",
|
||||
1002: "ModifiedTime",
|
||||
1003: "CreatedUser",
|
||||
1004: "ModifiedUser",
|
||||
1005: "AutoNumber",
|
||||
};
|
||||
|
||||
// ============ Core Functions ============
|
||||
|
||||
/** Parse bitable URL and extract tokens */
|
||||
function parseBitableUrl(url: string): { token: string; tableId?: string; isWiki: boolean } | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const tableId = u.searchParams.get("table") ?? undefined;
|
||||
|
||||
// Wiki format: /wiki/XXXXX?table=YYY
|
||||
const wikiMatch = u.pathname.match(/\/wiki\/([A-Za-z0-9]+)/);
|
||||
if (wikiMatch) {
|
||||
return { token: wikiMatch[1], tableId, isWiki: true };
|
||||
}
|
||||
|
||||
// Base format: /base/XXXXX?table=YYY
|
||||
const baseMatch = u.pathname.match(/\/base\/([A-Za-z0-9]+)/);
|
||||
if (baseMatch) {
|
||||
return { token: baseMatch[1], tableId, isWiki: false };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get app_token from wiki node_token */
|
||||
async function getAppTokenFromWiki(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
nodeToken: string,
|
||||
): Promise<string> {
|
||||
const res = await client.wiki.space.getNode({
|
||||
params: { token: nodeToken },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
const node = res.data?.node;
|
||||
if (!node) {
|
||||
throw new Error("Node not found");
|
||||
}
|
||||
if (node.obj_type !== "bitable") {
|
||||
throw new Error(`Node is not a bitable (type: ${node.obj_type})`);
|
||||
}
|
||||
|
||||
return node.obj_token!;
|
||||
}
|
||||
|
||||
/** Get bitable metadata from URL (handles both /base/ and /wiki/ URLs) */
|
||||
async function getBitableMeta(client: ReturnType<typeof createFeishuClient>, url: string) {
|
||||
const parsed = parseBitableUrl(url);
|
||||
if (!parsed) {
|
||||
throw new Error("Invalid URL format. Expected /base/XXX or /wiki/XXX URL");
|
||||
}
|
||||
|
||||
let appToken: string;
|
||||
if (parsed.isWiki) {
|
||||
appToken = await getAppTokenFromWiki(client, parsed.token);
|
||||
} else {
|
||||
appToken = parsed.token;
|
||||
}
|
||||
|
||||
// Get bitable app info
|
||||
const res = await client.bitable.app.get({
|
||||
path: { app_token: appToken },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
// List tables if no table_id specified
|
||||
let tables: { table_id: string; name: string }[] = [];
|
||||
if (!parsed.tableId) {
|
||||
const tablesRes = await client.bitable.appTable.list({
|
||||
path: { app_token: appToken },
|
||||
});
|
||||
if (tablesRes.code === 0) {
|
||||
tables = (tablesRes.data?.items ?? []).map((t) => ({
|
||||
table_id: t.table_id!,
|
||||
name: t.name!,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
app_token: appToken,
|
||||
table_id: parsed.tableId,
|
||||
name: res.data?.app?.name,
|
||||
url_type: parsed.isWiki ? "wiki" : "base",
|
||||
...(tables.length > 0 && { tables }),
|
||||
hint: parsed.tableId
|
||||
? `Use app_token="${appToken}" and table_id="${parsed.tableId}" for other bitable tools`
|
||||
: `Use app_token="${appToken}" for other bitable tools. Select a table_id from the tables list.`,
|
||||
};
|
||||
}
|
||||
|
||||
async function listFields(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
) {
|
||||
const res = await client.bitable.appTableField.list({
|
||||
path: { app_token: appToken, table_id: tableId },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
const fields = res.data?.items ?? [];
|
||||
return {
|
||||
fields: fields.map((f) => ({
|
||||
field_id: f.field_id,
|
||||
field_name: f.field_name,
|
||||
type: f.type,
|
||||
type_name: FIELD_TYPE_NAMES[f.type ?? 0] || `type_${f.type}`,
|
||||
is_primary: f.is_primary,
|
||||
...(f.property && { property: f.property }),
|
||||
})),
|
||||
total: fields.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function listRecords(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
pageSize?: number,
|
||||
pageToken?: string,
|
||||
) {
|
||||
const res = await client.bitable.appTableRecord.list({
|
||||
path: { app_token: appToken, table_id: tableId },
|
||||
params: {
|
||||
page_size: pageSize ?? 100,
|
||||
...(pageToken && { page_token: pageToken }),
|
||||
},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
records: res.data?.items ?? [],
|
||||
has_more: res.data?.has_more ?? false,
|
||||
page_token: res.data?.page_token,
|
||||
total: res.data?.total,
|
||||
};
|
||||
}
|
||||
|
||||
async function getRecord(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
recordId: string,
|
||||
) {
|
||||
const res = await client.bitable.appTableRecord.get({
|
||||
path: { app_token: appToken, table_id: tableId, record_id: recordId },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
record: res.data?.record,
|
||||
};
|
||||
}
|
||||
|
||||
async function createRecord(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
fields: Record<string, unknown>,
|
||||
) {
|
||||
const res = await client.bitable.appTableRecord.create({
|
||||
path: { app_token: appToken, table_id: tableId },
|
||||
data: { fields },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
record: res.data?.record,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateRecord(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
recordId: string,
|
||||
fields: Record<string, unknown>,
|
||||
) {
|
||||
const res = await client.bitable.appTableRecord.update({
|
||||
path: { app_token: appToken, table_id: tableId, record_id: recordId },
|
||||
data: { fields },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
record: res.data?.record,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Schemas ============
|
||||
|
||||
const GetMetaSchema = Type.Object({
|
||||
url: Type.String({
|
||||
description: "Bitable URL. Supports both formats: /base/XXX?table=YYY or /wiki/XXX?table=YYY",
|
||||
}),
|
||||
});
|
||||
|
||||
const ListFieldsSchema = Type.Object({
|
||||
app_token: Type.String({
|
||||
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
|
||||
}),
|
||||
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
|
||||
});
|
||||
|
||||
const ListRecordsSchema = Type.Object({
|
||||
app_token: Type.String({
|
||||
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
|
||||
}),
|
||||
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
|
||||
page_size: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of records per page (1-500, default 100)",
|
||||
minimum: 1,
|
||||
maximum: 500,
|
||||
}),
|
||||
),
|
||||
page_token: Type.Optional(
|
||||
Type.String({ description: "Pagination token from previous response" }),
|
||||
),
|
||||
});
|
||||
|
||||
const GetRecordSchema = Type.Object({
|
||||
app_token: Type.String({
|
||||
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
|
||||
}),
|
||||
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
|
||||
record_id: Type.String({ description: "Record ID to retrieve" }),
|
||||
});
|
||||
|
||||
const CreateRecordSchema = Type.Object({
|
||||
app_token: Type.String({
|
||||
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
|
||||
}),
|
||||
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
|
||||
fields: Type.Record(Type.String(), Type.Any(), {
|
||||
description:
|
||||
"Field values keyed by field name. Format by type: Text='string', Number=123, SingleSelect='Option', MultiSelect=['A','B'], DateTime=timestamp_ms, User=[{id:'ou_xxx'}], URL={text:'Display',link:'https://...'}",
|
||||
}),
|
||||
});
|
||||
|
||||
const UpdateRecordSchema = Type.Object({
|
||||
app_token: Type.String({
|
||||
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
|
||||
}),
|
||||
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
|
||||
record_id: Type.String({ description: "Record ID to update" }),
|
||||
fields: Type.Record(Type.String(), Type.Any(), {
|
||||
description: "Field values to update (same format as create_record)",
|
||||
}),
|
||||
});
|
||||
|
||||
// ============ Tool Registration ============
|
||||
|
||||
export function registerFeishuBitableTools(api: OpenClawPluginApi) {
|
||||
const feishuCfg = api.config?.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
|
||||
api.logger.debug?.("feishu_bitable: Feishu credentials not configured, skipping bitable tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const getClient = () => createFeishuClient(feishuCfg);
|
||||
|
||||
// Tool 0: feishu_bitable_get_meta (helper to parse URLs)
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_get_meta",
|
||||
label: "Feishu Bitable Get Meta",
|
||||
description:
|
||||
"Parse a Bitable URL and get app_token, table_id, and table list. Use this first when given a /wiki/ or /base/ URL.",
|
||||
parameters: GetMetaSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { url } = params as { url: string };
|
||||
try {
|
||||
const result = await getBitableMeta(getClient(), url);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_bitable_get_meta" },
|
||||
);
|
||||
|
||||
// Tool 1: feishu_bitable_list_fields
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_list_fields",
|
||||
label: "Feishu Bitable List Fields",
|
||||
description: "List all fields (columns) in a Bitable table with their types and properties",
|
||||
parameters: ListFieldsSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id } = params as { app_token: string; table_id: string };
|
||||
try {
|
||||
const result = await listFields(getClient(), app_token, table_id);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_bitable_list_fields" },
|
||||
);
|
||||
|
||||
// Tool 2: feishu_bitable_list_records
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_list_records",
|
||||
label: "Feishu Bitable List Records",
|
||||
description: "List records (rows) from a Bitable table with pagination support",
|
||||
parameters: ListRecordsSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id, page_size, page_token } = params as {
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
page_size?: number;
|
||||
page_token?: string;
|
||||
};
|
||||
try {
|
||||
const result = await listRecords(getClient(), app_token, table_id, page_size, page_token);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_bitable_list_records" },
|
||||
);
|
||||
|
||||
// Tool 3: feishu_bitable_get_record
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_get_record",
|
||||
label: "Feishu Bitable Get Record",
|
||||
description: "Get a single record by ID from a Bitable table",
|
||||
parameters: GetRecordSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id, record_id } = params as {
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
record_id: string;
|
||||
};
|
||||
try {
|
||||
const result = await getRecord(getClient(), app_token, table_id, record_id);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_bitable_get_record" },
|
||||
);
|
||||
|
||||
// Tool 4: feishu_bitable_create_record
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_create_record",
|
||||
label: "Feishu Bitable Create Record",
|
||||
description: "Create a new record (row) in a Bitable table",
|
||||
parameters: CreateRecordSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id, fields } = params as {
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
fields: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
const result = await createRecord(getClient(), app_token, table_id, fields);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_bitable_create_record" },
|
||||
);
|
||||
|
||||
// Tool 5: feishu_bitable_update_record
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_update_record",
|
||||
label: "Feishu Bitable Update Record",
|
||||
description: "Update an existing record (row) in a Bitable table",
|
||||
parameters: UpdateRecordSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id, record_id, fields } = params as {
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
record_id: string;
|
||||
fields: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
const result = await updateRecord(getClient(), app_token, table_id, record_id, fields);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_bitable_update_record" },
|
||||
);
|
||||
|
||||
api.logger.info?.(`feishu_bitable: Registered 6 bitable tools`);
|
||||
}
|
||||
@@ -1,871 +0,0 @@
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { downloadMessageResourceFeishu } from "./media.js";
|
||||
import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
|
||||
import {
|
||||
resolveFeishuGroupConfig,
|
||||
resolveFeishuReplyPolicy,
|
||||
resolveFeishuAllowlistMatch,
|
||||
isFeishuGroupAllowed,
|
||||
} from "./policy.js";
|
||||
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { getMessageFeishu } from "./send.js";
|
||||
|
||||
// --- Permission error extraction ---
|
||||
// Extract permission grant URL from Feishu API error response.
|
||||
type PermissionError = {
|
||||
code: number;
|
||||
message: string;
|
||||
grantUrl?: string;
|
||||
};
|
||||
|
||||
function extractPermissionError(err: unknown): PermissionError | null {
|
||||
if (!err || typeof err !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Axios error structure: err.response.data contains the Feishu error
|
||||
const axiosErr = err as { response?: { data?: unknown } };
|
||||
const data = axiosErr.response?.data;
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const feishuErr = data as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
error?: { permission_violations?: Array<{ uri?: string }> };
|
||||
};
|
||||
|
||||
// Feishu permission error code: 99991672
|
||||
if (feishuErr.code !== 99991672) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the grant URL from the error message (contains the direct link)
|
||||
const msg = feishuErr.msg ?? "";
|
||||
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
|
||||
const grantUrl = urlMatch?.[0];
|
||||
|
||||
return {
|
||||
code: feishuErr.code,
|
||||
message: msg,
|
||||
grantUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
|
||||
// Cache display names by open_id to avoid an API call on every message.
|
||||
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
|
||||
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
|
||||
|
||||
// Cache permission errors to avoid spamming the user with repeated notifications.
|
||||
// Key: appId or "default", Value: timestamp of last notification
|
||||
const permissionErrorNotifiedAt = new Map<string, number>();
|
||||
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
type SenderNameResult = {
|
||||
name?: string;
|
||||
permissionError?: PermissionError;
|
||||
};
|
||||
|
||||
async function resolveFeishuSenderName(params: {
|
||||
account: ResolvedFeishuAccount;
|
||||
senderOpenId: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function
|
||||
log: (...args: any[]) => void;
|
||||
}): Promise<SenderNameResult> {
|
||||
const { account, senderOpenId, log } = params;
|
||||
if (!account.configured) {
|
||||
return {};
|
||||
}
|
||||
if (!senderOpenId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const cached = senderNameCache.get(senderOpenId);
|
||||
const now = Date.now();
|
||||
if (cached && cached.expireAt > now) {
|
||||
return { name: cached.name };
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
// contact/v3/users/:user_id?user_id_type=open_id
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
||||
const res: any = await client.contact.user.get({
|
||||
path: { user_id: senderOpenId },
|
||||
params: { user_id_type: "open_id" },
|
||||
});
|
||||
|
||||
const name: string | undefined =
|
||||
res?.data?.user?.name ||
|
||||
res?.data?.user?.display_name ||
|
||||
res?.data?.user?.nickname ||
|
||||
res?.data?.user?.en_name;
|
||||
|
||||
if (name && typeof name === "string") {
|
||||
senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
||||
return { name };
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (err) {
|
||||
// Check if this is a permission error
|
||||
const permErr = extractPermissionError(err);
|
||||
if (permErr) {
|
||||
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
|
||||
return { permissionError: permErr };
|
||||
}
|
||||
|
||||
// Best-effort. Don't fail message handling if name lookup fails.
|
||||
log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export type FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
union_id?: string;
|
||||
};
|
||||
sender_type?: string;
|
||||
tenant_key?: string;
|
||||
};
|
||||
message: {
|
||||
message_id: string;
|
||||
root_id?: string;
|
||||
parent_id?: string;
|
||||
chat_id: string;
|
||||
chat_type: "p2p" | "group";
|
||||
message_type: string;
|
||||
content: string;
|
||||
mentions?: Array<{
|
||||
key: string;
|
||||
id: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
union_id?: string;
|
||||
};
|
||||
name: string;
|
||||
tenant_key?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type FeishuBotAddedEvent = {
|
||||
chat_id: string;
|
||||
operator_id: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
union_id?: string;
|
||||
};
|
||||
external: boolean;
|
||||
operator_tenant_key?: string;
|
||||
};
|
||||
|
||||
function parseMessageContent(content: string, messageType: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (messageType === "text") {
|
||||
return parsed.text || "";
|
||||
}
|
||||
if (messageType === "post") {
|
||||
// Extract text content from rich text post
|
||||
const { textContent } = parsePostContent(content);
|
||||
return textContent;
|
||||
}
|
||||
return content;
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
||||
const mentions = event.message.mentions ?? [];
|
||||
if (mentions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!botOpenId) {
|
||||
return mentions.length > 0;
|
||||
}
|
||||
return mentions.some((m) => m.id.open_id === botOpenId);
|
||||
}
|
||||
|
||||
function stripBotMention(
|
||||
text: string,
|
||||
mentions?: FeishuMessageEvent["message"]["mentions"],
|
||||
): string {
|
||||
if (!mentions || mentions.length === 0) {
|
||||
return text;
|
||||
}
|
||||
let result = text;
|
||||
for (const mention of mentions) {
|
||||
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
|
||||
result = result.replace(new RegExp(mention.key, "g"), "").trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse media keys from message content based on message type.
|
||||
*/
|
||||
function parseMediaKeys(
|
||||
content: string,
|
||||
messageType: string,
|
||||
): {
|
||||
imageKey?: string;
|
||||
fileKey?: string;
|
||||
fileName?: string;
|
||||
} {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
switch (messageType) {
|
||||
case "image":
|
||||
return { imageKey: parsed.image_key };
|
||||
case "file":
|
||||
return { fileKey: parsed.file_key, fileName: parsed.file_name };
|
||||
case "audio":
|
||||
return { fileKey: parsed.file_key };
|
||||
case "video":
|
||||
// Video has both file_key (video) and image_key (thumbnail)
|
||||
return { fileKey: parsed.file_key, imageKey: parsed.image_key };
|
||||
case "sticker":
|
||||
return { fileKey: parsed.file_key };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse post (rich text) content and extract embedded image keys.
|
||||
* Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
|
||||
*/
|
||||
function parsePostContent(content: string): {
|
||||
textContent: string;
|
||||
imageKeys: string[];
|
||||
} {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const title = parsed.title || "";
|
||||
const contentBlocks = parsed.content || [];
|
||||
let textContent = title ? `${title}\n\n` : "";
|
||||
const imageKeys: string[] = [];
|
||||
|
||||
for (const paragraph of contentBlocks) {
|
||||
if (Array.isArray(paragraph)) {
|
||||
for (const element of paragraph) {
|
||||
if (element.tag === "text") {
|
||||
textContent += element.text || "";
|
||||
} else if (element.tag === "a") {
|
||||
// Link: show text or href
|
||||
textContent += element.text || element.href || "";
|
||||
} else if (element.tag === "at") {
|
||||
// Mention: @username
|
||||
textContent += `@${element.user_name || element.user_id || ""}`;
|
||||
} else if (element.tag === "img" && element.image_key) {
|
||||
// Embedded image
|
||||
imageKeys.push(element.image_key);
|
||||
}
|
||||
}
|
||||
textContent += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
textContent: textContent.trim() || "[富文本消息]",
|
||||
imageKeys,
|
||||
};
|
||||
} catch {
|
||||
return { textContent: "[富文本消息]", imageKeys: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer placeholder text based on message type.
|
||||
*/
|
||||
function inferPlaceholder(messageType: string): string {
|
||||
switch (messageType) {
|
||||
case "image":
|
||||
return "<media:image>";
|
||||
case "file":
|
||||
return "<media:document>";
|
||||
case "audio":
|
||||
return "<media:audio>";
|
||||
case "video":
|
||||
return "<media:video>";
|
||||
case "sticker":
|
||||
return "<media:sticker>";
|
||||
default:
|
||||
return "<media:document>";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve media from a Feishu message, downloading and saving to disk.
|
||||
* Similar to Discord's resolveMediaList().
|
||||
*/
|
||||
async function resolveFeishuMediaList(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
messageType: string;
|
||||
content: string;
|
||||
maxBytes: number;
|
||||
log?: (msg: string) => void;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuMediaInfo[]> {
|
||||
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
|
||||
|
||||
// Only process media message types (including post for embedded images)
|
||||
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
|
||||
if (!mediaTypes.includes(messageType)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: FeishuMediaInfo[] = [];
|
||||
const core = getFeishuRuntime();
|
||||
|
||||
// Handle post (rich text) messages with embedded images
|
||||
if (messageType === "post") {
|
||||
const { imageKeys } = parsePostContent(content);
|
||||
if (imageKeys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
||||
|
||||
for (const imageKey of imageKeys) {
|
||||
try {
|
||||
// Embedded images in post use messageResource API with image_key as file_key
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg,
|
||||
messageId,
|
||||
fileKey: imageKey,
|
||||
type: "image",
|
||||
accountId,
|
||||
});
|
||||
|
||||
let contentType = result.contentType;
|
||||
if (!contentType) {
|
||||
contentType = await core.media.detectMime({ buffer: result.buffer });
|
||||
}
|
||||
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
result.buffer,
|
||||
contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
);
|
||||
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:image>",
|
||||
});
|
||||
|
||||
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
|
||||
} catch (err) {
|
||||
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Handle other media types
|
||||
const mediaKeys = parseMediaKeys(content, messageType);
|
||||
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
let buffer: Buffer;
|
||||
let contentType: string | undefined;
|
||||
let fileName: string | undefined;
|
||||
|
||||
// For message media, always use messageResource API
|
||||
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
|
||||
const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
|
||||
if (!fileKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resourceType = messageType === "image" ? "image" : "file";
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg,
|
||||
messageId,
|
||||
fileKey,
|
||||
type: resourceType,
|
||||
accountId,
|
||||
});
|
||||
buffer = result.buffer;
|
||||
contentType = result.contentType;
|
||||
fileName = result.fileName || mediaKeys.fileName;
|
||||
|
||||
// Detect mime type if not provided
|
||||
if (!contentType) {
|
||||
contentType = await core.media.detectMime({ buffer });
|
||||
}
|
||||
|
||||
// Save to disk using core's saveMediaBuffer
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
fileName,
|
||||
);
|
||||
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder(messageType),
|
||||
});
|
||||
|
||||
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
|
||||
} catch (err) {
|
||||
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build media payload for inbound context.
|
||||
* Similar to Discord's buildDiscordMediaPayload().
|
||||
*/
|
||||
function buildFeishuMediaPayload(mediaList: FeishuMediaInfo[]): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
const first = mediaList[0];
|
||||
const mediaPaths = mediaList.map((media) => media.path);
|
||||
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
||||
return {
|
||||
MediaPath: first?.path,
|
||||
MediaType: first?.contentType,
|
||||
MediaUrl: first?.path,
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFeishuMessageEvent(
|
||||
event: FeishuMessageEvent,
|
||||
botOpenId?: string,
|
||||
): FeishuMessageContext {
|
||||
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
||||
const mentionedBot = checkBotMentioned(event, botOpenId);
|
||||
const content = stripBotMention(rawContent, event.message.mentions);
|
||||
|
||||
const ctx: FeishuMessageContext = {
|
||||
chatId: event.message.chat_id,
|
||||
messageId: event.message.message_id,
|
||||
senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "",
|
||||
senderOpenId: event.sender.sender_id.open_id || "",
|
||||
chatType: event.message.chat_type,
|
||||
mentionedBot,
|
||||
rootId: event.message.root_id || undefined,
|
||||
parentId: event.message.parent_id || undefined,
|
||||
content,
|
||||
contentType: event.message.message_type,
|
||||
};
|
||||
|
||||
// Detect mention forward request: message mentions bot + at least one other user
|
||||
if (isMentionForwardRequest(event, botOpenId)) {
|
||||
const mentionTargets = extractMentionTargets(event, botOpenId);
|
||||
if (mentionTargets.length > 0) {
|
||||
ctx.mentionTargets = mentionTargets;
|
||||
// Extract message body (remove all @ placeholders)
|
||||
const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
|
||||
ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function handleFeishuMessage(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
event: FeishuMessageEvent;
|
||||
botOpenId?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
chatHistories?: Map<string, HistoryEntry[]>;
|
||||
accountId?: string;
|
||||
}): Promise<void> {
|
||||
const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
|
||||
|
||||
// Resolve account with merged config
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
const feishuCfg = account.config;
|
||||
|
||||
const log = runtime?.log ?? console.log;
|
||||
const error = runtime?.error ?? console.error;
|
||||
|
||||
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
||||
const isGroup = ctx.chatType === "group";
|
||||
|
||||
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
||||
const senderResult = await resolveFeishuSenderName({
|
||||
account,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
log,
|
||||
});
|
||||
if (senderResult.name) {
|
||||
ctx = { ...ctx, senderName: senderResult.name };
|
||||
}
|
||||
|
||||
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
||||
let permissionErrorForAgent: PermissionError | undefined;
|
||||
if (senderResult.permissionError) {
|
||||
const appKey = account.appId ?? "default";
|
||||
const now = Date.now();
|
||||
const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
|
||||
|
||||
if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
|
||||
permissionErrorNotifiedAt.set(appKey, now);
|
||||
permissionErrorForAgent = senderResult.permissionError;
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
`feishu[${account.accountId}]: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`,
|
||||
);
|
||||
|
||||
// Log mention targets if detected
|
||||
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
||||
const names = ctx.mentionTargets.map((t) => t.name).join(", ");
|
||||
log(`feishu[${account.accountId}]: detected @ forward request, targets: [${names}]`);
|
||||
}
|
||||
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
|
||||
if (isGroup) {
|
||||
const groupPolicy = feishuCfg?.groupPolicy ?? "open";
|
||||
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
|
||||
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
|
||||
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
||||
|
||||
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
|
||||
const groupAllowed = isFeishuGroupAllowed({
|
||||
groupPolicy,
|
||||
allowFrom: groupAllowFrom,
|
||||
senderId: ctx.chatId, // Check group ID, not sender ID
|
||||
senderName: undefined,
|
||||
});
|
||||
|
||||
if (!groupAllowed) {
|
||||
log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in group allowlist`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional sender-level allowlist check if group has specific allowFrom config
|
||||
const senderAllowFrom = groupConfig?.allowFrom ?? [];
|
||||
if (senderAllowFrom.length > 0) {
|
||||
const senderAllowed = isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: senderAllowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
senderName: ctx.senderName,
|
||||
});
|
||||
if (!senderAllowed) {
|
||||
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { requireMention } = resolveFeishuReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
globalConfig: feishuCfg,
|
||||
groupConfig,
|
||||
});
|
||||
|
||||
if (requireMention && !ctx.mentionedBot) {
|
||||
log(
|
||||
`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
|
||||
);
|
||||
if (chatHistories) {
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: chatHistories,
|
||||
historyKey: ctx.chatId,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: ctx.senderOpenId,
|
||||
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
|
||||
timestamp: Date.now(),
|
||||
messageId: ctx.messageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
||||
const allowFrom = feishuCfg?.allowFrom ?? [];
|
||||
|
||||
if (dmPolicy === "allowlist") {
|
||||
const match = resolveFeishuAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
});
|
||||
if (!match.allowed) {
|
||||
log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const core = getFeishuRuntime();
|
||||
|
||||
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
||||
// Using a group-scoped From causes the agent to treat different users as the same person.
|
||||
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
||||
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup ? ctx.chatId : ctx.senderOpenId,
|
||||
},
|
||||
});
|
||||
|
||||
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel = isGroup
|
||||
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
||||
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
|
||||
|
||||
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
|
||||
});
|
||||
|
||||
// Resolve media from message
|
||||
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
|
||||
const mediaList = await resolveFeishuMediaList({
|
||||
cfg,
|
||||
messageId: ctx.messageId,
|
||||
messageType: event.message.message_type,
|
||||
content: event.message.content,
|
||||
maxBytes: mediaMaxBytes,
|
||||
log,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const mediaPayload = buildFeishuMediaPayload(mediaList);
|
||||
|
||||
// Fetch quoted/replied message content if parentId exists
|
||||
let quotedContent: string | undefined;
|
||||
if (ctx.parentId) {
|
||||
try {
|
||||
const quotedMsg = await getMessageFeishu({
|
||||
cfg,
|
||||
messageId: ctx.parentId,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
if (quotedMsg) {
|
||||
quotedContent = quotedMsg.content;
|
||||
log(
|
||||
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
|
||||
// Build message body with quoted content if available
|
||||
let messageBody = ctx.content;
|
||||
if (quotedContent) {
|
||||
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
||||
}
|
||||
|
||||
// Include a readable speaker label so the model can attribute instructions.
|
||||
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
|
||||
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
||||
messageBody = `${speaker}: ${messageBody}`;
|
||||
|
||||
// If there are mention targets, inform the agent that replies will auto-mention them
|
||||
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
||||
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
||||
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
||||
}
|
||||
|
||||
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
||||
|
||||
// If there's a permission error, dispatch a separate notification first
|
||||
if (permissionErrorForAgent) {
|
||||
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
|
||||
const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
|
||||
|
||||
const permissionBody = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Feishu",
|
||||
from: envelopeFrom,
|
||||
timestamp: new Date(),
|
||||
envelope: envelopeOptions,
|
||||
body: permissionNotifyBody,
|
||||
});
|
||||
|
||||
const permissionCtx = core.channel.reply.finalizeInboundContext({
|
||||
Body: permissionBody,
|
||||
RawBody: permissionNotifyBody,
|
||||
CommandBody: permissionNotifyBody,
|
||||
From: feishuFrom,
|
||||
To: feishuTo,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
GroupSubject: isGroup ? ctx.chatId : undefined,
|
||||
SenderName: "system",
|
||||
SenderId: "system",
|
||||
Provider: "feishu" as const,
|
||||
Surface: "feishu" as const,
|
||||
MessageSid: `${ctx.messageId}:permission-error`,
|
||||
Timestamp: Date.now(),
|
||||
WasMentioned: false,
|
||||
CommandAuthorized: true,
|
||||
OriginatingChannel: "feishu" as const,
|
||||
OriginatingTo: feishuTo,
|
||||
});
|
||||
|
||||
const {
|
||||
dispatcher: permDispatcher,
|
||||
replyOptions: permReplyOptions,
|
||||
markDispatchIdle: markPermIdle,
|
||||
} = createFeishuReplyDispatcher({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
runtime: runtime as RuntimeEnv,
|
||||
chatId: ctx.chatId,
|
||||
replyToMessageId: ctx.messageId,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
|
||||
|
||||
await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: permissionCtx,
|
||||
cfg,
|
||||
dispatcher: permDispatcher,
|
||||
replyOptions: permReplyOptions,
|
||||
});
|
||||
|
||||
markPermIdle();
|
||||
}
|
||||
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Feishu",
|
||||
from: envelopeFrom,
|
||||
timestamp: new Date(),
|
||||
envelope: envelopeOptions,
|
||||
body: messageBody,
|
||||
});
|
||||
|
||||
let combinedBody = body;
|
||||
const historyKey = isGroup ? ctx.chatId : undefined;
|
||||
|
||||
if (isGroup && historyKey && chatHistories) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: chatHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Feishu",
|
||||
// Preserve speaker identity in group history as well.
|
||||
from: `${ctx.chatId}:${entry.sender}`,
|
||||
timestamp: entry.timestamp,
|
||||
body: entry.body,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
RawBody: ctx.content,
|
||||
CommandBody: ctx.content,
|
||||
From: feishuFrom,
|
||||
To: feishuTo,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
GroupSubject: isGroup ? ctx.chatId : undefined,
|
||||
SenderName: ctx.senderName ?? ctx.senderOpenId,
|
||||
SenderId: ctx.senderOpenId,
|
||||
Provider: "feishu" as const,
|
||||
Surface: "feishu" as const,
|
||||
MessageSid: ctx.messageId,
|
||||
Timestamp: Date.now(),
|
||||
WasMentioned: ctx.mentionedBot,
|
||||
CommandAuthorized: true,
|
||||
OriginatingChannel: "feishu" as const,
|
||||
OriginatingTo: feishuTo,
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
runtime: runtime as RuntimeEnv,
|
||||
chatId: ctx.chatId,
|
||||
replyToMessageId: ctx.messageId,
|
||||
mentionTargets: ctx.mentionTargets,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
||||
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
});
|
||||
|
||||
markDispatchIdle();
|
||||
|
||||
if (isGroup && historyKey && chatHistories) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: chatHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
|
||||
log(
|
||||
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|
||||
);
|
||||
} catch (err) {
|
||||
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,55 @@
|
||||
import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
||||
import {
|
||||
resolveFeishuAccount,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
feishuOutbound,
|
||||
formatPairingApproveHint,
|
||||
listFeishuAccountIds,
|
||||
monitorFeishuProvider,
|
||||
normalizeFeishuTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
probeFeishu,
|
||||
resolveDefaultFeishuAccountId,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
listFeishuDirectoryPeers,
|
||||
listFeishuDirectoryGroups,
|
||||
listFeishuDirectoryPeersLive,
|
||||
listFeishuDirectoryGroupsLive,
|
||||
} from "./directory.js";
|
||||
resolveFeishuAccount,
|
||||
resolveFeishuConfig,
|
||||
resolveFeishuGroupRequireMention,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelAccountSnapshot,
|
||||
type ChannelPlugin,
|
||||
type ChannelStatusIssue,
|
||||
type ResolvedFeishuAccount,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import { feishuOnboardingAdapter } from "./onboarding.js";
|
||||
import { feishuOutbound } from "./outbound.js";
|
||||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
import { sendMessageFeishu } from "./send.js";
|
||||
import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";
|
||||
|
||||
const meta = {
|
||||
id: "feishu",
|
||||
label: "Feishu",
|
||||
selectionLabel: "Feishu/Lark (飞书)",
|
||||
selectionLabel: "Feishu (Lark Open Platform)",
|
||||
detailLabel: "Feishu Bot",
|
||||
docsPath: "/channels/feishu",
|
||||
docsLabel: "feishu",
|
||||
blurb: "飞书/Lark enterprise messaging.",
|
||||
blurb: "Feishu/Lark bot via WebSocket.",
|
||||
aliases: ["lark"],
|
||||
order: 70,
|
||||
} as const;
|
||||
order: 35,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
const normalizeAllowEntry = (entry: string) => entry.replace(/^(feishu|lark):/i, "").trim();
|
||||
|
||||
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
id: "feishu",
|
||||
meta: {
|
||||
...meta,
|
||||
},
|
||||
meta,
|
||||
onboarding: feishuOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "feishuUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
|
||||
notifyApproval: async ({ cfg, id, accountId }) => {
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: id,
|
||||
text: PAIRING_APPROVED_MESSAGE,
|
||||
accountId,
|
||||
});
|
||||
idLabel: "feishuOpenId",
|
||||
normalizeAllowEntry: normalizeAllowEntry,
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveFeishuAccount({ cfg });
|
||||
if (!account.config.appId || !account.config.appSecret) {
|
||||
throw new Error("Feishu app credentials not configured");
|
||||
}
|
||||
await feishuOutbound.sendText({ cfg, to: id, text: PAIRING_APPROVED_MESSAGE });
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
@@ -56,233 +61,113 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
nativeCommands: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.",
|
||||
"- Feishu supports interactive cards for rich messages.",
|
||||
],
|
||||
},
|
||||
groups: {
|
||||
resolveToolPolicy: resolveFeishuGroupToolPolicy,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.feishu"] },
|
||||
configSchema: {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
appId: { type: "string" },
|
||||
appSecret: { type: "string" },
|
||||
encryptKey: { type: "string" },
|
||||
verificationToken: { type: "string" },
|
||||
domain: {
|
||||
oneOf: [
|
||||
{ type: "string", enum: ["feishu", "lark"] },
|
||||
{ type: "string", format: "uri", pattern: "^https://" },
|
||||
],
|
||||
},
|
||||
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
||||
webhookPath: { type: "string" },
|
||||
webhookPort: { type: "integer", minimum: 1 },
|
||||
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
|
||||
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
|
||||
groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
|
||||
groupAllowFrom: {
|
||||
type: "array",
|
||||
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
requireMention: { type: "boolean" },
|
||||
historyLimit: { type: "integer", minimum: 0 },
|
||||
dmHistoryLimit: { type: "integer", minimum: 0 },
|
||||
textChunkLimit: { type: "integer", minimum: 1 },
|
||||
chunkMode: { type: "string", enum: ["length", "newline"] },
|
||||
mediaMaxMb: { type: "number", minimum: 0 },
|
||||
renderMode: { type: "string", enum: ["auto", "raw", "card"] },
|
||||
accounts: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
name: { type: "string" },
|
||||
appId: { type: "string" },
|
||||
appSecret: { type: "string" },
|
||||
encryptKey: { type: "string" },
|
||||
verificationToken: { type: "string" },
|
||||
domain: { type: "string", enum: ["feishu", "lark"] },
|
||||
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
outbound: feishuOutbound,
|
||||
messaging: {
|
||||
normalizeTarget: normalizeFeishuTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw, normalized) => {
|
||||
const value = (normalized ?? raw).trim();
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return /^o[cun]_[a-zA-Z0-9]+$/.test(value) || /^(user|group|chat):/i.test(value);
|
||||
},
|
||||
hint: "<open_id|union_id|chat_id>",
|
||||
},
|
||||
},
|
||||
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||
const _account = resolveFeishuAccount({ cfg, accountId });
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
|
||||
if (isDefault) {
|
||||
// For default account, set top-level enabled
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For named accounts, set enabled in accounts[accountId]
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...feishuCfg,
|
||||
accounts: {
|
||||
...feishuCfg?.accounts,
|
||||
[accountId]: {
|
||||
...feishuCfg?.accounts?.[accountId],
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
deleteAccount: ({ cfg, accountId }) => {
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
|
||||
if (isDefault) {
|
||||
// Delete entire feishu config
|
||||
const next = { ...cfg } as ClawdbotConfig;
|
||||
const nextChannels = { ...cfg.channels };
|
||||
delete (nextChannels as Record<string, unknown>).feishu;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
next.channels = nextChannels;
|
||||
} else {
|
||||
delete next.channels;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// Delete specific account from accounts
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const accounts = { ...feishuCfg?.accounts };
|
||||
delete accounts[accountId];
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...feishuCfg,
|
||||
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) => ({
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "feishu",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "feishu",
|
||||
accountId,
|
||||
clearBaseFields: ["appId", "appSecret", "appSecretFile", "name", "botName"],
|
||||
}),
|
||||
isConfigured: (account) => account.tokenSource !== "none",
|
||||
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
name: account.name,
|
||||
appId: account.appId,
|
||||
domain: account.domain,
|
||||
enabled: account.enabled,
|
||||
configured: account.tokenSource !== "none",
|
||||
tokenSource: account.tokenSource,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
return account.config?.allowFrom ?? [];
|
||||
},
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
resolveFeishuConfig({ cfg, accountId: accountId ?? undefined }).allowFrom.map((entry) =>
|
||||
String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
.map((entry) => (entry === "*" ? entry : normalizeAllowEntry(entry)))
|
||||
.map((entry) => (entry === "*" ? entry : entry.toLowerCase())),
|
||||
},
|
||||
security: {
|
||||
collectWarnings: ({ cfg, accountId }) => {
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
const feishuCfg = account.config;
|
||||
const defaultGroupPolicy = (
|
||||
cfg.channels as Record<string, { groupPolicy?: string }> | undefined
|
||||
)?.defaults?.groupPolicy;
|
||||
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
|
||||
];
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
applyAccountConfig: ({ cfg, accountId }) => {
|
||||
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
|
||||
|
||||
if (isDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(cfg.channels?.feishu?.accounts?.[resolvedAccountId]);
|
||||
const basePath = useAccountPath
|
||||
? `channels.feishu.accounts.${resolvedAccountId}.`
|
||||
: "channels.feishu.";
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...feishuCfg,
|
||||
accounts: {
|
||||
...feishuCfg?.accounts,
|
||||
[accountId]: {
|
||||
...feishuCfg?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: formatPairingApproveHint("feishu"),
|
||||
normalizeEntry: normalizeAllowEntry,
|
||||
};
|
||||
},
|
||||
},
|
||||
onboarding: feishuOnboardingAdapter,
|
||||
messaging: {
|
||||
normalizeTarget: normalizeFeishuTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeFeishuId,
|
||||
hint: "<chatId|user:openId|chat:chatId>",
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
if (!groupId) {
|
||||
return true;
|
||||
}
|
||||
return resolveFeishuGroupRequireMention({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
chatId: groupId,
|
||||
});
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, query, limit, accountId }) =>
|
||||
listFeishuDirectoryPeers({ cfg, query, limit, accountId }),
|
||||
listGroups: async ({ cfg, query, limit, accountId }) =>
|
||||
listFeishuDirectoryGroups({ cfg, query, limit, accountId }),
|
||||
listPeersLive: async ({ cfg, query, limit, accountId }) =>
|
||||
listFeishuDirectoryPeersLive({ cfg, query, limit, accountId }),
|
||||
listGroupsLive: async ({ cfg, query, limit, accountId }) =>
|
||||
listFeishuDirectoryGroupsLive({ cfg, query, limit, accountId }),
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const resolved = resolveFeishuConfig({ cfg, accountId: accountId ?? undefined });
|
||||
const normalizedQuery = query?.trim().toLowerCase() ?? "";
|
||||
const peers = resolved.allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry) => Boolean(entry) && entry !== "*")
|
||||
.map((entry) => normalizeAllowEntry(entry))
|
||||
.filter((entry) => (normalizedQuery ? entry.toLowerCase().includes(normalizedQuery) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
return peers;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const resolved = resolveFeishuConfig({ cfg, accountId: accountId ?? undefined });
|
||||
const normalizedQuery = query?.trim().toLowerCase() ?? "";
|
||||
const groups = Object.keys(resolved.groups ?? {})
|
||||
.filter((id) => (normalizedQuery ? id.toLowerCase().includes(normalizedQuery) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return groups;
|
||||
},
|
||||
},
|
||||
outbound: feishuOutbound,
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
@@ -290,52 +175,102 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
port: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
collectStatusIssues: (accounts) => {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
for (const account of accounts) {
|
||||
if (!account.configured) {
|
||||
issues.push({
|
||||
channel: "feishu",
|
||||
accountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
kind: "config",
|
||||
message: "Feishu app ID/secret not configured",
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
buildChannelSummary: async ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
tokenSource: snapshot.tokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
port: snapshot.port ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ cfg, accountId }) => {
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
return await probeFeishu(account);
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
probeFeishu(account.config.appId, account.config.appSecret, timeoutMs, account.config.domain),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
const configured = account.tokenSource !== "none";
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
tokenSource: account.tokenSource,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
};
|
||||
},
|
||||
logSelfId: ({ account, runtime }) => {
|
||||
const appId = account.config.appId;
|
||||
if (appId) {
|
||||
runtime.log?.(`feishu:${appId}`);
|
||||
}
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
name: account.name,
|
||||
appId: account.appId,
|
||||
domain: account.domain,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
port: runtime?.port ?? null,
|
||||
probe,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const { monitorFeishuProvider } = await import("./monitor.js");
|
||||
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
|
||||
const port = account.config?.webhookPort ?? null;
|
||||
ctx.setStatus({ accountId: ctx.accountId, port });
|
||||
ctx.log?.info(
|
||||
`starting feishu[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? "websocket"})`,
|
||||
);
|
||||
return monitorFeishuProvider({
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
accountId: ctx.accountId,
|
||||
const { account, log, setStatus, abortSignal, cfg, runtime } = ctx;
|
||||
const { appId, appSecret, domain } = account.config;
|
||||
if (!appId || !appSecret) {
|
||||
throw new Error("Feishu app ID/secret not configured");
|
||||
}
|
||||
|
||||
let feishuBotLabel = "";
|
||||
try {
|
||||
const probe = await probeFeishu(appId, appSecret, 5000, domain);
|
||||
if (probe.ok && probe.bot?.appName) {
|
||||
feishuBotLabel = ` (${probe.bot.appName})`;
|
||||
}
|
||||
if (probe.ok && probe.bot) {
|
||||
setStatus({ accountId: account.accountId, bot: probe.bot });
|
||||
}
|
||||
} catch (err) {
|
||||
log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
log?.info(`[${account.accountId}] starting Feishu provider${feishuBotLabel}`);
|
||||
setStatus({
|
||||
accountId: account.accountId,
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
await monitorFeishuProvider({
|
||||
appId,
|
||||
appSecret,
|
||||
accountId: account.accountId,
|
||||
config: cfg,
|
||||
runtime,
|
||||
abortSignal,
|
||||
});
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
accountId: account.accountId,
|
||||
running: false,
|
||||
lastError: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { FeishuDomain, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
// Multi-account client cache
|
||||
const clientCache = new Map<
|
||||
string,
|
||||
{
|
||||
client: Lark.Client;
|
||||
config: { appId: string; appSecret: string; domain?: FeishuDomain };
|
||||
}
|
||||
>();
|
||||
|
||||
function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
|
||||
if (domain === "lark") {
|
||||
return Lark.Domain.Lark;
|
||||
}
|
||||
if (domain === "feishu" || !domain) {
|
||||
return Lark.Domain.Feishu;
|
||||
}
|
||||
return domain.replace(/\/+$/, ""); // Custom URL for private deployment
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials needed to create a Feishu client.
|
||||
* Both FeishuConfig and ResolvedFeishuAccount satisfy this interface.
|
||||
*/
|
||||
export type FeishuClientCredentials = {
|
||||
accountId?: string;
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
domain?: FeishuDomain;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or get a cached Feishu client for an account.
|
||||
* Accepts any object with appId, appSecret, and optional domain/accountId.
|
||||
*/
|
||||
export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client {
|
||||
const { accountId = "default", appId, appSecret, domain } = creds;
|
||||
|
||||
if (!appId || !appSecret) {
|
||||
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cached = clientCache.get(accountId);
|
||||
if (
|
||||
cached &&
|
||||
cached.config.appId === appId &&
|
||||
cached.config.appSecret === appSecret &&
|
||||
cached.config.domain === domain
|
||||
) {
|
||||
return cached.client;
|
||||
}
|
||||
|
||||
// Create new client
|
||||
const client = new Lark.Client({
|
||||
appId,
|
||||
appSecret,
|
||||
appType: Lark.AppType.SelfBuild,
|
||||
domain: resolveDomain(domain),
|
||||
});
|
||||
|
||||
// Cache it
|
||||
clientCache.set(accountId, {
|
||||
client,
|
||||
config: { appId, appSecret, domain },
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Feishu WebSocket client for an account.
|
||||
* Note: WSClient is not cached since each call creates a new connection.
|
||||
*/
|
||||
export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSClient {
|
||||
const { accountId, appId, appSecret, domain } = account;
|
||||
|
||||
if (!appId || !appSecret) {
|
||||
throw new Error(`Feishu credentials not configured for account "${accountId}"`);
|
||||
}
|
||||
|
||||
return new Lark.WSClient({
|
||||
appId,
|
||||
appSecret,
|
||||
domain: resolveDomain(domain),
|
||||
loggerLevel: Lark.LoggerLevel.info,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event dispatcher for an account.
|
||||
*/
|
||||
export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
|
||||
return new Lark.EventDispatcher({
|
||||
encryptKey: account.encryptKey,
|
||||
verificationToken: account.verificationToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached client for an account (if exists).
|
||||
*/
|
||||
export function getFeishuClient(accountId: string): Lark.Client | null {
|
||||
return clientCache.get(accountId)?.client ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear client cache for a specific account or all accounts.
|
||||
*/
|
||||
export function clearClientCache(accountId?: string): void {
|
||||
if (accountId) {
|
||||
clientCache.delete(accountId);
|
||||
} else {
|
||||
clientCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,172 +1,47 @@
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
export { z };
|
||||
|
||||
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
|
||||
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
|
||||
const FeishuDomainSchema = z.union([
|
||||
z.enum(["feishu", "lark"]),
|
||||
z.string().url().startsWith("https://"),
|
||||
]);
|
||||
const FeishuConnectionModeSchema = z.enum(["websocket", "webhook"]);
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
const toolsBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
|
||||
|
||||
const ToolPolicySchema = z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const DmConfigSchema = z
|
||||
const FeishuGroupSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const MarkdownConfigSchema = z
|
||||
.object({
|
||||
mode: z.enum(["native", "escape", "strip"]).optional(),
|
||||
tableMode: z.enum(["native", "ascii", "simple"]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
|
||||
const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
|
||||
|
||||
const BlockStreamingCoalesceSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
minDelayMs: z.number().int().positive().optional(),
|
||||
maxDelayMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const ChannelHeartbeatVisibilitySchema = z
|
||||
.object({
|
||||
visibility: z.enum(["visible", "hidden"]).optional(),
|
||||
intervalMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Feishu tools configuration.
|
||||
* Controls which tool categories are enabled.
|
||||
*
|
||||
* Dependencies:
|
||||
* - wiki requires doc (wiki content is edited via doc tools)
|
||||
* - perm can work independently but is typically used with drive
|
||||
*/
|
||||
const FeishuToolsConfigSchema = z
|
||||
.object({
|
||||
doc: z.boolean().optional(), // Document operations (default: true)
|
||||
wiki: z.boolean().optional(), // Knowledge base operations (default: true, requires doc)
|
||||
drive: z.boolean().optional(), // Cloud storage operations (default: true)
|
||||
perm: z.boolean().optional(), // Permission management (default: false, sensitive)
|
||||
scopes: z.boolean().optional(), // App scopes diagnostic (default: true)
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const FeishuGroupSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
tools: ToolPolicySchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
toolsBySender: toolsBySenderSchema,
|
||||
systemPrompt: z.string().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Per-account configuration.
|
||||
* All fields are optional - missing fields inherit from top-level config.
|
||||
*/
|
||||
export const FeishuAccountConfigSchema = z
|
||||
const FeishuAccountSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
name: z.string().optional(), // Display name for this account
|
||||
appId: z.string().optional(),
|
||||
appSecret: z.string().optional(),
|
||||
encryptKey: z.string().optional(),
|
||||
verificationToken: z.string().optional(),
|
||||
domain: FeishuDomainSchema.optional(),
|
||||
connectionMode: FeishuConnectionModeSchema.optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
webhookPort: z.number().int().positive().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
appSecretFile: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
botName: z.string().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
configWrites: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
historyLimit: z.number().optional(),
|
||||
dmHistoryLimit: z.number().optional(),
|
||||
textChunkLimit: z.number().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
renderMode: RenderModeSchema,
|
||||
tools: FeishuToolsConfigSchema,
|
||||
blockStreaming: z.boolean().optional(),
|
||||
streaming: z.boolean().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const FeishuConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
// Top-level credentials (backward compatible for single-account mode)
|
||||
appId: z.string().optional(),
|
||||
appSecret: z.string().optional(),
|
||||
encryptKey: z.string().optional(),
|
||||
verificationToken: z.string().optional(),
|
||||
domain: FeishuDomainSchema.optional().default("feishu"),
|
||||
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
|
||||
webhookPath: z.string().optional().default("/feishu/events"),
|
||||
webhookPort: z.number().int().positive().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
configWrites: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
requireMention: z.boolean().optional().default(true),
|
||||
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
|
||||
tools: FeishuToolsConfigSchema,
|
||||
// Multi-account configuration
|
||||
accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.dmPolicy === "open") {
|
||||
const allowFrom = value.allowFrom ?? [];
|
||||
const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
|
||||
if (!hasWildcard) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.feishu.dmPolicy="open" requires channels.feishu.allowFrom to include "*"',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export const FeishuConfigSchema = FeishuAccountSchema.extend({
|
||||
accounts: z.object({}).catchall(FeishuAccountSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { normalizeFeishuTarget } from "./targets.js";
|
||||
|
||||
export type FeishuDirectoryPeer = {
|
||||
kind: "user";
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type FeishuDirectoryGroup = {
|
||||
kind: "group";
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export async function listFeishuDirectoryPeers(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuDirectoryPeer[]> {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const feishuCfg = account.config;
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const entry of feishuCfg?.allowFrom ?? []) {
|
||||
const trimmed = String(entry).trim();
|
||||
if (trimmed && trimmed !== "*") {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
|
||||
const trimmed = userId.trim();
|
||||
if (trimmed) {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => normalizeFeishuTarget(raw) ?? raw)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "user" as const, id }));
|
||||
}
|
||||
|
||||
export async function listFeishuDirectoryGroups(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuDirectoryGroup[]> {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const feishuCfg = account.config;
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
|
||||
const trimmed = groupId.trim();
|
||||
if (trimmed && trimmed !== "*") {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of feishuCfg?.groupAllowFrom ?? []) {
|
||||
const trimmed = String(entry).trim();
|
||||
if (trimmed && trimmed !== "*") {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "group" as const, id }));
|
||||
}
|
||||
|
||||
export async function listFeishuDirectoryPeersLive(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuDirectoryPeer[]> {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
if (!account.configured) {
|
||||
return listFeishuDirectoryPeers(params);
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(account);
|
||||
const peers: FeishuDirectoryPeer[] = [];
|
||||
const limit = params.limit ?? 50;
|
||||
|
||||
const response = await client.contact.user.list({
|
||||
params: {
|
||||
page_size: Math.min(limit, 50),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code === 0 && response.data?.items) {
|
||||
for (const user of response.data.items) {
|
||||
if (user.open_id) {
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const name = user.name || "";
|
||||
if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
||||
peers.push({
|
||||
kind: "user",
|
||||
id: user.open_id,
|
||||
name: name || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (peers.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return peers;
|
||||
} catch {
|
||||
return listFeishuDirectoryPeers(params);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFeishuDirectoryGroupsLive(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuDirectoryGroup[]> {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
if (!account.configured) {
|
||||
return listFeishuDirectoryGroups(params);
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(account);
|
||||
const groups: FeishuDirectoryGroup[] = [];
|
||||
const limit = params.limit ?? 50;
|
||||
|
||||
const response = await client.im.chat.list({
|
||||
params: {
|
||||
page_size: Math.min(limit, 100),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code === 0 && response.data?.items) {
|
||||
for (const chat of response.data.items) {
|
||||
if (chat.chat_id) {
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const name = chat.name || "";
|
||||
if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
||||
groups.push({
|
||||
kind: "group",
|
||||
id: chat.chat_id,
|
||||
name: name || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (groups.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
} catch {
|
||||
return listFeishuDirectoryGroups(params);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
export const FeishuDocSchema = Type.Union([
|
||||
Type.Object({
|
||||
action: Type.Literal("read"),
|
||||
doc_token: Type.String({ description: "Document token (extract from URL /docx/XXX)" }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("write"),
|
||||
doc_token: Type.String({ description: "Document token" }),
|
||||
content: Type.String({
|
||||
description: "Markdown content to write (replaces entire document content)",
|
||||
}),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("append"),
|
||||
doc_token: Type.String({ description: "Document token" }),
|
||||
content: Type.String({ description: "Markdown content to append to end of document" }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("create"),
|
||||
title: Type.String({ description: "Document title" }),
|
||||
folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("list_blocks"),
|
||||
doc_token: Type.String({ description: "Document token" }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("get_block"),
|
||||
doc_token: Type.String({ description: "Document token" }),
|
||||
block_id: Type.String({ description: "Block ID (from list_blocks)" }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("update_block"),
|
||||
doc_token: Type.String({ description: "Document token" }),
|
||||
block_id: Type.String({ description: "Block ID (from list_blocks)" }),
|
||||
content: Type.String({ description: "New text content" }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("delete_block"),
|
||||
doc_token: Type.String({ description: "Document token" }),
|
||||
block_id: Type.String({ description: "Block ID" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type FeishuDocParams = Static<typeof FeishuDocSchema>;
|
||||
@@ -1,521 +0,0 @@
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { Readable } from "stream";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
|
||||
import { resolveToolsConfig } from "./tools-config.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
function json(data: unknown) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract image URLs from markdown content */
|
||||
function extractImageUrls(markdown: string): string[] {
|
||||
const regex = /!\[[^\]]*\]\(([^)]+)\)/g;
|
||||
const urls: string[] = [];
|
||||
let match;
|
||||
while ((match = regex.exec(markdown)) !== null) {
|
||||
const url = match[1].trim();
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
const BLOCK_TYPE_NAMES: Record<number, string> = {
|
||||
1: "Page",
|
||||
2: "Text",
|
||||
3: "Heading1",
|
||||
4: "Heading2",
|
||||
5: "Heading3",
|
||||
12: "Bullet",
|
||||
13: "Ordered",
|
||||
14: "Code",
|
||||
15: "Quote",
|
||||
17: "Todo",
|
||||
18: "Bitable",
|
||||
21: "Diagram",
|
||||
22: "Divider",
|
||||
23: "File",
|
||||
27: "Image",
|
||||
30: "Sheet",
|
||||
31: "Table",
|
||||
32: "TableCell",
|
||||
};
|
||||
|
||||
// Block types that cannot be created via documentBlockChildren.create API
|
||||
const UNSUPPORTED_CREATE_TYPES = new Set([31, 32]);
|
||||
|
||||
/** Clean blocks for insertion (remove unsupported types and read-only fields) */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
|
||||
function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } {
|
||||
const skipped: string[] = [];
|
||||
const cleaned = blocks
|
||||
.filter((block) => {
|
||||
if (UNSUPPORTED_CREATE_TYPES.has(block.block_type)) {
|
||||
const typeName = BLOCK_TYPE_NAMES[block.block_type] || `type_${block.block_type}`;
|
||||
skipped.push(typeName);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((block) => {
|
||||
if (block.block_type === 31 && block.table?.merge_info) {
|
||||
const { merge_info: _merge_info, ...tableRest } = block.table;
|
||||
return { ...block, table: tableRest };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
return { cleaned, skipped };
|
||||
}
|
||||
|
||||
// ============ Core Functions ============
|
||||
|
||||
async function convertMarkdown(client: Lark.Client, markdown: string) {
|
||||
const res = await client.docx.document.convert({
|
||||
data: { content_type: "markdown", content: markdown },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
return {
|
||||
blocks: res.data?.blocks ?? [],
|
||||
firstLevelBlockIds: res.data?.first_level_block_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
|
||||
async function insertBlocks(
|
||||
client: Lark.Client,
|
||||
docToken: string,
|
||||
blocks: any[],
|
||||
parentBlockId?: string,
|
||||
): Promise<{ children: any[]; skipped: string[] }> {
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
const { cleaned, skipped } = cleanBlocksForInsert(blocks);
|
||||
const blockId = parentBlockId ?? docToken;
|
||||
|
||||
if (cleaned.length === 0) {
|
||||
return { children: [], skipped };
|
||||
}
|
||||
|
||||
const res = await client.docx.documentBlockChildren.create({
|
||||
path: { document_id: docToken, block_id: blockId },
|
||||
data: { children: cleaned },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
return { children: res.data?.children ?? [], skipped };
|
||||
}
|
||||
|
||||
async function clearDocumentContent(client: Lark.Client, docToken: string) {
|
||||
const existing = await client.docx.documentBlock.list({
|
||||
path: { document_id: docToken },
|
||||
});
|
||||
if (existing.code !== 0) {
|
||||
throw new Error(existing.msg);
|
||||
}
|
||||
|
||||
const childIds =
|
||||
existing.data?.items
|
||||
?.filter((b) => b.parent_id === docToken && b.block_type !== 1)
|
||||
.map((b) => b.block_id) ?? [];
|
||||
|
||||
if (childIds.length > 0) {
|
||||
const res = await client.docx.documentBlockChildren.batchDelete({
|
||||
path: { document_id: docToken, block_id: docToken },
|
||||
data: { start_index: 0, end_index: childIds.length },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
}
|
||||
|
||||
return childIds.length;
|
||||
}
|
||||
|
||||
async function uploadImageToDocx(
|
||||
client: Lark.Client,
|
||||
blockId: string,
|
||||
imageBuffer: Buffer,
|
||||
fileName: string,
|
||||
): Promise<string> {
|
||||
const res = await client.drive.media.uploadAll({
|
||||
data: {
|
||||
file_name: fileName,
|
||||
parent_type: "docx_image",
|
||||
parent_node: blockId,
|
||||
size: imageBuffer.length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
|
||||
file: Readable.from(imageBuffer) as any,
|
||||
},
|
||||
});
|
||||
|
||||
const fileToken = res?.file_token;
|
||||
if (!fileToken) {
|
||||
throw new Error("Image upload failed: no file_token returned");
|
||||
}
|
||||
return fileToken;
|
||||
}
|
||||
|
||||
async function downloadImage(url: string): Promise<Buffer> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
|
||||
async function processImages(
|
||||
client: Lark.Client,
|
||||
docToken: string,
|
||||
markdown: string,
|
||||
insertedBlocks: any[],
|
||||
): Promise<number> {
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
const imageUrls = extractImageUrls(markdown);
|
||||
if (imageUrls.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const imageBlocks = insertedBlocks.filter((b) => b.block_type === 27);
|
||||
|
||||
let processed = 0;
|
||||
for (let i = 0; i < Math.min(imageUrls.length, imageBlocks.length); i++) {
|
||||
const url = imageUrls[i];
|
||||
const blockId = imageBlocks[i].block_id;
|
||||
|
||||
try {
|
||||
const buffer = await downloadImage(url);
|
||||
const urlPath = new URL(url).pathname;
|
||||
const fileName = urlPath.split("/").pop() || `image_${i}.png`;
|
||||
const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName);
|
||||
|
||||
await client.docx.documentBlock.patch({
|
||||
path: { document_id: docToken, block_id: blockId },
|
||||
data: {
|
||||
replace_image: { token: fileToken },
|
||||
},
|
||||
});
|
||||
|
||||
processed++;
|
||||
} catch (err) {
|
||||
console.error(`Failed to process image ${url}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
// ============ Actions ============
|
||||
|
||||
const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]);
|
||||
|
||||
async function readDoc(client: Lark.Client, docToken: string) {
|
||||
const [contentRes, infoRes, blocksRes] = await Promise.all([
|
||||
client.docx.document.rawContent({ path: { document_id: docToken } }),
|
||||
client.docx.document.get({ path: { document_id: docToken } }),
|
||||
client.docx.documentBlock.list({ path: { document_id: docToken } }),
|
||||
]);
|
||||
|
||||
if (contentRes.code !== 0) {
|
||||
throw new Error(contentRes.msg);
|
||||
}
|
||||
|
||||
const blocks = blocksRes.data?.items ?? [];
|
||||
const blockCounts: Record<string, number> = {};
|
||||
const structuredTypes: string[] = [];
|
||||
|
||||
for (const b of blocks) {
|
||||
const type = b.block_type ?? 0;
|
||||
const name = BLOCK_TYPE_NAMES[type] || `type_${type}`;
|
||||
blockCounts[name] = (blockCounts[name] || 0) + 1;
|
||||
|
||||
if (STRUCTURED_BLOCK_TYPES.has(type) && !structuredTypes.includes(name)) {
|
||||
structuredTypes.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
let hint: string | undefined;
|
||||
if (structuredTypes.length > 0) {
|
||||
hint = `This document contains ${structuredTypes.join(", ")} which are NOT included in the plain text above. Use feishu_doc with action: "list_blocks" to get full content.`;
|
||||
}
|
||||
|
||||
return {
|
||||
title: infoRes.data?.document?.title,
|
||||
content: contentRes.data?.content,
|
||||
revision_id: infoRes.data?.document?.revision_id,
|
||||
block_count: blocks.length,
|
||||
block_types: blockCounts,
|
||||
...(hint && { hint }),
|
||||
};
|
||||
}
|
||||
|
||||
async function createDoc(client: Lark.Client, title: string, folderToken?: string) {
|
||||
const res = await client.docx.document.create({
|
||||
data: { title, folder_token: folderToken },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
const doc = res.data?.document;
|
||||
return {
|
||||
document_id: doc?.document_id,
|
||||
title: doc?.title,
|
||||
url: `https://feishu.cn/docx/${doc?.document_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeDoc(client: Lark.Client, docToken: string, markdown: string) {
|
||||
const deleted = await clearDocumentContent(client, docToken);
|
||||
|
||||
const { blocks } = await convertMarkdown(client, markdown);
|
||||
if (blocks.length === 0) {
|
||||
return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
|
||||
}
|
||||
|
||||
const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
|
||||
const imagesProcessed = await processImages(client, docToken, markdown, inserted);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
blocks_deleted: deleted,
|
||||
blocks_added: inserted.length,
|
||||
images_processed: imagesProcessed,
|
||||
...(skipped.length > 0 && {
|
||||
warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
|
||||
const { blocks } = await convertMarkdown(client, markdown);
|
||||
if (blocks.length === 0) {
|
||||
throw new Error("Content is empty");
|
||||
}
|
||||
|
||||
const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
|
||||
const imagesProcessed = await processImages(client, docToken, markdown, inserted);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
blocks_added: inserted.length,
|
||||
images_processed: imagesProcessed,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
|
||||
block_ids: inserted.map((b: any) => b.block_id),
|
||||
...(skipped.length > 0 && {
|
||||
warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function updateBlock(
|
||||
client: Lark.Client,
|
||||
docToken: string,
|
||||
blockId: string,
|
||||
content: string,
|
||||
) {
|
||||
const blockInfo = await client.docx.documentBlock.get({
|
||||
path: { document_id: docToken, block_id: blockId },
|
||||
});
|
||||
if (blockInfo.code !== 0) {
|
||||
throw new Error(blockInfo.msg);
|
||||
}
|
||||
|
||||
const res = await client.docx.documentBlock.patch({
|
||||
path: { document_id: docToken, block_id: blockId },
|
||||
data: {
|
||||
update_text_elements: {
|
||||
elements: [{ text_run: { content } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return { success: true, block_id: blockId };
|
||||
}
|
||||
|
||||
async function deleteBlock(client: Lark.Client, docToken: string, blockId: string) {
|
||||
const blockInfo = await client.docx.documentBlock.get({
|
||||
path: { document_id: docToken, block_id: blockId },
|
||||
});
|
||||
if (blockInfo.code !== 0) {
|
||||
throw new Error(blockInfo.msg);
|
||||
}
|
||||
|
||||
const parentId = blockInfo.data?.block?.parent_id ?? docToken;
|
||||
|
||||
const children = await client.docx.documentBlockChildren.get({
|
||||
path: { document_id: docToken, block_id: parentId },
|
||||
});
|
||||
if (children.code !== 0) {
|
||||
throw new Error(children.msg);
|
||||
}
|
||||
|
||||
const items = children.data?.items ?? [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
|
||||
const index = items.findIndex((item: any) => item.block_id === blockId);
|
||||
if (index === -1) {
|
||||
throw new Error("Block not found");
|
||||
}
|
||||
|
||||
const res = await client.docx.documentBlockChildren.batchDelete({
|
||||
path: { document_id: docToken, block_id: parentId },
|
||||
data: { start_index: index, end_index: index + 1 },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return { success: true, deleted_block_id: blockId };
|
||||
}
|
||||
|
||||
async function listBlocks(client: Lark.Client, docToken: string) {
|
||||
const res = await client.docx.documentBlock.list({
|
||||
path: { document_id: docToken },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
blocks: res.data?.items ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function getBlock(client: Lark.Client, docToken: string, blockId: string) {
|
||||
const res = await client.docx.documentBlock.get({
|
||||
path: { document_id: docToken, block_id: blockId },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
block: res.data?.block,
|
||||
};
|
||||
}
|
||||
|
||||
async function listAppScopes(client: Lark.Client) {
|
||||
const res = await client.application.scope.list({});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
const scopes = res.data?.scopes ?? [];
|
||||
const granted = scopes.filter((s) => s.grant_status === 1);
|
||||
const pending = scopes.filter((s) => s.grant_status !== 1);
|
||||
|
||||
return {
|
||||
granted: granted.map((s) => ({ name: s.scope_name, type: s.scope_type })),
|
||||
pending: pending.map((s) => ({ name: s.scope_name, type: s.scope_type })),
|
||||
summary: `${granted.length} granted, ${pending.length} pending`,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Tool Registration ============
|
||||
|
||||
export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_doc: No config available, skipping doc tools");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any account is configured
|
||||
const accounts = listEnabledFeishuAccounts(api.config);
|
||||
if (accounts.length === 0) {
|
||||
api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first account's config for tools configuration
|
||||
const firstAccount = accounts[0];
|
||||
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
||||
|
||||
// Helper to get client for the default account
|
||||
const getClient = () => createFeishuClient(firstAccount);
|
||||
const registered: string[] = [];
|
||||
|
||||
// Main document tool with action-based dispatch
|
||||
if (toolsCfg.doc) {
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_doc",
|
||||
label: "Feishu Doc",
|
||||
description:
|
||||
"Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block",
|
||||
parameters: FeishuDocSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuDocParams;
|
||||
try {
|
||||
const client = getClient();
|
||||
switch (p.action) {
|
||||
case "read":
|
||||
return json(await readDoc(client, p.doc_token));
|
||||
case "write":
|
||||
return json(await writeDoc(client, p.doc_token, p.content));
|
||||
case "append":
|
||||
return json(await appendDoc(client, p.doc_token, p.content));
|
||||
case "create":
|
||||
return json(await createDoc(client, p.title, p.folder_token));
|
||||
case "list_blocks":
|
||||
return json(await listBlocks(client, p.doc_token));
|
||||
case "get_block":
|
||||
return json(await getBlock(client, p.doc_token, p.block_id));
|
||||
case "update_block":
|
||||
return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
|
||||
case "delete_block":
|
||||
return json(await deleteBlock(client, p.doc_token, p.block_id));
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
||||
return json({ error: `Unknown action: ${(p as any).action}` });
|
||||
}
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_doc" },
|
||||
);
|
||||
registered.push("feishu_doc");
|
||||
}
|
||||
|
||||
// Keep feishu_app_scopes as independent tool
|
||||
if (toolsCfg.scopes) {
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_app_scopes",
|
||||
label: "Feishu App Scopes",
|
||||
description:
|
||||
"List current app permissions (scopes). Use to debug permission issues or check available capabilities.",
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
try {
|
||||
const result = await listAppScopes(getClient());
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_app_scopes" },
|
||||
);
|
||||
registered.push("feishu_app_scopes");
|
||||
}
|
||||
|
||||
if (registered.length > 0) {
|
||||
api.logger.info?.(`feishu_doc: Registered ${registered.join(", ")}`);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
const FileType = Type.Union([
|
||||
Type.Literal("doc"),
|
||||
Type.Literal("docx"),
|
||||
Type.Literal("sheet"),
|
||||
Type.Literal("bitable"),
|
||||
Type.Literal("folder"),
|
||||
Type.Literal("file"),
|
||||
Type.Literal("mindnote"),
|
||||
Type.Literal("shortcut"),
|
||||
]);
|
||||
|
||||
export const FeishuDriveSchema = Type.Union([
|
||||
Type.Object({
|
||||
action: Type.Literal("list"),
|
||||
folder_token: Type.Optional(
|
||||
Type.String({ description: "Folder token (optional, omit for root directory)" }),
|
||||
),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("info"),
|
||||
file_token: Type.String({ description: "File or folder token" }),
|
||||
type: FileType,
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("create_folder"),
|
||||
name: Type.String({ description: "Folder name" }),
|
||||
folder_token: Type.Optional(
|
||||
Type.String({ description: "Parent folder token (optional, omit for root)" }),
|
||||
),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("move"),
|
||||
file_token: Type.String({ description: "File token to move" }),
|
||||
type: FileType,
|
||||
folder_token: Type.String({ description: "Target folder token" }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("delete"),
|
||||
file_token: Type.String({ description: "File token to delete" }),
|
||||
type: FileType,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type FeishuDriveParams = Static<typeof FeishuDriveSchema>;
|
||||
@@ -1,227 +0,0 @@
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
|
||||
import { resolveToolsConfig } from "./tools-config.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
function json(data: unknown) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Actions ============
|
||||
|
||||
async function getRootFolderToken(client: Lark.Client): Promise<string> {
|
||||
// Use generic HTTP client to call the root folder meta API
|
||||
// as it's not directly exposed in the SDK
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing internal SDK property
|
||||
const domain = (client as any).domain ?? "https://open.feishu.cn";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing internal SDK property
|
||||
const res = (await (client as any).httpInstance.get(
|
||||
`${domain}/open-apis/drive/explorer/v2/root_folder/meta`,
|
||||
)) as { code: number; msg?: string; data?: { token?: string } };
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg ?? "Failed to get root folder");
|
||||
}
|
||||
const token = res.data?.token;
|
||||
if (!token) {
|
||||
throw new Error("Root folder token not found");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function listFolder(client: Lark.Client, folderToken?: string) {
|
||||
// Filter out invalid folder_token values (empty, "0", etc.)
|
||||
const validFolderToken = folderToken && folderToken !== "0" ? folderToken : undefined;
|
||||
const res = await client.drive.file.list({
|
||||
params: validFolderToken ? { folder_token: validFolderToken } : {},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
files:
|
||||
res.data?.files?.map((f) => ({
|
||||
token: f.token,
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
url: f.url,
|
||||
created_time: f.created_time,
|
||||
modified_time: f.modified_time,
|
||||
owner_id: f.owner_id,
|
||||
})) ?? [],
|
||||
next_page_token: res.data?.next_page_token,
|
||||
};
|
||||
}
|
||||
|
||||
async function getFileInfo(client: Lark.Client, fileToken: string, folderToken?: string) {
|
||||
// Use list with folder_token to find file info
|
||||
const res = await client.drive.file.list({
|
||||
params: folderToken ? { folder_token: folderToken } : {},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
const file = res.data?.files?.find((f) => f.token === fileToken);
|
||||
if (!file) {
|
||||
throw new Error(`File not found: ${fileToken}`);
|
||||
}
|
||||
|
||||
return {
|
||||
token: file.token,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
url: file.url,
|
||||
created_time: file.created_time,
|
||||
modified_time: file.modified_time,
|
||||
owner_id: file.owner_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function createFolder(client: Lark.Client, name: string, folderToken?: string) {
|
||||
// Feishu supports using folder_token="0" as the root folder.
|
||||
// We *try* to resolve the real root token (explorer API), but fall back to "0"
|
||||
// because some tenants/apps return 400 for that explorer endpoint.
|
||||
let effectiveToken = folderToken && folderToken !== "0" ? folderToken : "0";
|
||||
if (effectiveToken === "0") {
|
||||
try {
|
||||
effectiveToken = await getRootFolderToken(client);
|
||||
} catch {
|
||||
// ignore and keep "0"
|
||||
}
|
||||
}
|
||||
|
||||
const res = await client.drive.file.createFolder({
|
||||
data: {
|
||||
name,
|
||||
folder_token: effectiveToken,
|
||||
},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
token: res.data?.token,
|
||||
url: res.data?.url,
|
||||
};
|
||||
}
|
||||
|
||||
async function moveFile(client: Lark.Client, fileToken: string, type: string, folderToken: string) {
|
||||
const res = await client.drive.file.move({
|
||||
path: { file_token: fileToken },
|
||||
data: {
|
||||
type: type as
|
||||
| "doc"
|
||||
| "docx"
|
||||
| "sheet"
|
||||
| "bitable"
|
||||
| "folder"
|
||||
| "file"
|
||||
| "mindnote"
|
||||
| "slides",
|
||||
folder_token: folderToken,
|
||||
},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task_id: res.data?.task_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteFile(client: Lark.Client, fileToken: string, type: string) {
|
||||
const res = await client.drive.file.delete({
|
||||
path: { file_token: fileToken },
|
||||
params: {
|
||||
type: type as
|
||||
| "doc"
|
||||
| "docx"
|
||||
| "sheet"
|
||||
| "bitable"
|
||||
| "folder"
|
||||
| "file"
|
||||
| "mindnote"
|
||||
| "slides"
|
||||
| "shortcut",
|
||||
},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task_id: res.data?.task_id,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Tool Registration ============
|
||||
|
||||
export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_drive: No config available, skipping drive tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = listEnabledFeishuAccounts(api.config);
|
||||
if (accounts.length === 0) {
|
||||
api.logger.debug?.("feishu_drive: No Feishu accounts configured, skipping drive tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAccount = accounts[0];
|
||||
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
||||
if (!toolsCfg.drive) {
|
||||
api.logger.debug?.("feishu_drive: drive tool disabled in config");
|
||||
return;
|
||||
}
|
||||
|
||||
const getClient = () => createFeishuClient(firstAccount);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_drive",
|
||||
label: "Feishu Drive",
|
||||
description:
|
||||
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete",
|
||||
parameters: FeishuDriveSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuDriveParams;
|
||||
try {
|
||||
const client = getClient();
|
||||
switch (p.action) {
|
||||
case "list":
|
||||
return json(await listFolder(client, p.folder_token));
|
||||
case "info":
|
||||
return json(await getFileInfo(client, p.file_token));
|
||||
case "create_folder":
|
||||
return json(await createFolder(client, p.name, p.folder_token));
|
||||
case "move":
|
||||
return json(await moveFile(client, p.file_token, p.type, p.folder_token));
|
||||
case "delete":
|
||||
return json(await deleteFile(client, p.file_token, p.type));
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
||||
return json({ error: `Unknown action: ${(p as any).action}` });
|
||||
}
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_drive" },
|
||||
);
|
||||
|
||||
api.logger.info?.(`feishu_drive: Registered feishu_drive tool`);
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { Readable } from "stream";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
||||
|
||||
export type DownloadImageResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type DownloadMessageResourceResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Download an image from Feishu using image_key.
|
||||
* Used for downloading images sent in messages.
|
||||
*/
|
||||
export async function downloadImageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
imageKey: string;
|
||||
accountId?: string;
|
||||
}): Promise<DownloadImageResult> {
|
||||
const { cfg, imageKey, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
const response = await client.im.image.get({
|
||||
path: { image_key: imageKey },
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(
|
||||
`Feishu image download failed: ${responseAny.msg || `code ${responseAny.code}`}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle various response formats from Feishu SDK
|
||||
let buffer: Buffer;
|
||||
|
||||
if (Buffer.isBuffer(response)) {
|
||||
buffer = response;
|
||||
} else if (response instanceof ArrayBuffer) {
|
||||
buffer = Buffer.from(response);
|
||||
} else if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
|
||||
buffer = responseAny.data;
|
||||
} else if (responseAny.data instanceof ArrayBuffer) {
|
||||
buffer = Buffer.from(responseAny.data);
|
||||
} else if (typeof responseAny.getReadableStream === "function") {
|
||||
// SDK provides getReadableStream method
|
||||
const stream = responseAny.getReadableStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else if (typeof responseAny.writeFile === "function") {
|
||||
// SDK provides writeFile method - use a temp file
|
||||
const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`);
|
||||
await responseAny.writeFile(tmpPath);
|
||||
buffer = await fs.promises.readFile(tmpPath);
|
||||
await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup
|
||||
} else if (typeof responseAny[Symbol.asyncIterator] === "function") {
|
||||
// Response is an async iterable
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of responseAny) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else if (typeof responseAny.read === "function") {
|
||||
// Response is a Readable stream
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of responseAny as Readable) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else {
|
||||
// Debug: log what we actually received
|
||||
const keys = Object.keys(responseAny);
|
||||
const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", ");
|
||||
throw new Error(`Feishu image download failed: unexpected response format. Keys: [${types}]`);
|
||||
}
|
||||
|
||||
return { buffer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a message resource (file/image/audio/video) from Feishu.
|
||||
* Used for downloading files, audio, and video from messages.
|
||||
*/
|
||||
export async function downloadMessageResourceFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
fileKey: string;
|
||||
type: "image" | "file";
|
||||
accountId?: string;
|
||||
}): Promise<DownloadMessageResourceResult> {
|
||||
const { cfg, messageId, fileKey, type, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
const response = await client.im.messageResource.get({
|
||||
path: { message_id: messageId, file_key: fileKey },
|
||||
params: { type },
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(
|
||||
`Feishu message resource download failed: ${responseAny.msg || `code ${responseAny.code}`}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle various response formats from Feishu SDK
|
||||
let buffer: Buffer;
|
||||
|
||||
if (Buffer.isBuffer(response)) {
|
||||
buffer = response;
|
||||
} else if (response instanceof ArrayBuffer) {
|
||||
buffer = Buffer.from(response);
|
||||
} else if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
|
||||
buffer = responseAny.data;
|
||||
} else if (responseAny.data instanceof ArrayBuffer) {
|
||||
buffer = Buffer.from(responseAny.data);
|
||||
} else if (typeof responseAny.getReadableStream === "function") {
|
||||
// SDK provides getReadableStream method
|
||||
const stream = responseAny.getReadableStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else if (typeof responseAny.writeFile === "function") {
|
||||
// SDK provides writeFile method - use a temp file
|
||||
const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`);
|
||||
await responseAny.writeFile(tmpPath);
|
||||
buffer = await fs.promises.readFile(tmpPath);
|
||||
await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup
|
||||
} else if (typeof responseAny[Symbol.asyncIterator] === "function") {
|
||||
// Response is an async iterable
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of responseAny) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else if (typeof responseAny.read === "function") {
|
||||
// Response is a Readable stream
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of responseAny as Readable) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else {
|
||||
// Debug: log what we actually received
|
||||
const keys = Object.keys(responseAny);
|
||||
const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", ");
|
||||
throw new Error(
|
||||
`Feishu message resource download failed: unexpected response format. Keys: [${types}]`,
|
||||
);
|
||||
}
|
||||
|
||||
return { buffer };
|
||||
}
|
||||
|
||||
export type UploadImageResult = {
|
||||
imageKey: string;
|
||||
};
|
||||
|
||||
export type UploadFileResult = {
|
||||
fileKey: string;
|
||||
};
|
||||
|
||||
export type SendMediaResult = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload an image to Feishu and get an image_key for sending.
|
||||
* Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO
|
||||
*/
|
||||
export async function uploadImageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
image: Buffer | string; // Buffer or file path
|
||||
imageType?: "message" | "avatar";
|
||||
accountId?: string;
|
||||
}): Promise<UploadImageResult> {
|
||||
const { cfg, image, imageType = "message", accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
// SDK expects a Readable stream, not a Buffer
|
||||
// Use type assertion since SDK actually accepts any Readable at runtime
|
||||
const imageStream = typeof image === "string" ? fs.createReadStream(image) : Readable.from(image);
|
||||
|
||||
const response = await client.im.image.create({
|
||||
data: {
|
||||
image_type: imageType,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
|
||||
image: imageStream as any,
|
||||
},
|
||||
});
|
||||
|
||||
// SDK v1.30+ returns data directly without code wrapper on success
|
||||
// On error, it throws or returns { code, msg }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
|
||||
}
|
||||
|
||||
const imageKey = responseAny.image_key ?? responseAny.data?.image_key;
|
||||
if (!imageKey) {
|
||||
throw new Error("Feishu image upload failed: no image_key returned");
|
||||
}
|
||||
|
||||
return { imageKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to Feishu and get a file_key for sending.
|
||||
* Max file size: 30MB
|
||||
*/
|
||||
export async function uploadFileFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
file: Buffer | string; // Buffer or file path
|
||||
fileName: string;
|
||||
fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
|
||||
duration?: number; // Required for audio/video files, in milliseconds
|
||||
accountId?: string;
|
||||
}): Promise<UploadFileResult> {
|
||||
const { cfg, file, fileName, fileType, duration, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
// SDK expects a Readable stream, not a Buffer
|
||||
// Use type assertion since SDK actually accepts any Readable at runtime
|
||||
const fileStream = typeof file === "string" ? fs.createReadStream(file) : Readable.from(file);
|
||||
|
||||
const response = await client.im.file.create({
|
||||
data: {
|
||||
file_type: fileType,
|
||||
file_name: fileName,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
|
||||
file: fileStream as any,
|
||||
...(duration !== undefined && { duration }),
|
||||
},
|
||||
});
|
||||
|
||||
// SDK v1.30+ returns data directly without code wrapper on success
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
|
||||
}
|
||||
|
||||
const fileKey = responseAny.file_key ?? responseAny.data?.file_key;
|
||||
if (!fileKey) {
|
||||
throw new Error("Feishu file upload failed: no file_key returned");
|
||||
}
|
||||
|
||||
return { fileKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an image message using an image_key
|
||||
*/
|
||||
export async function sendImageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
imageKey: string;
|
||||
replyToMessageId?: string;
|
||||
accountId?: string;
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, imageKey, replyToMessageId, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const content = JSON.stringify({ image_key: imageKey });
|
||||
|
||||
if (replyToMessageId) {
|
||||
const response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: "image",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu image reply failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: "image",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu image send failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file message using a file_key
|
||||
*/
|
||||
export async function sendFileFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
fileKey: string;
|
||||
replyToMessageId?: string;
|
||||
accountId?: string;
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, fileKey, replyToMessageId, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const content = JSON.stringify({ file_key: fileKey });
|
||||
|
||||
if (replyToMessageId) {
|
||||
const response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: "file",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu file reply failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: "file",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu file send failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to detect file type from extension
|
||||
*/
|
||||
export function detectFileType(
|
||||
fileName: string,
|
||||
): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
switch (ext) {
|
||||
case ".opus":
|
||||
case ".ogg":
|
||||
return "opus";
|
||||
case ".mp4":
|
||||
case ".mov":
|
||||
case ".avi":
|
||||
return "mp4";
|
||||
case ".pdf":
|
||||
return "pdf";
|
||||
case ".doc":
|
||||
case ".docx":
|
||||
return "doc";
|
||||
case ".xls":
|
||||
case ".xlsx":
|
||||
return "xls";
|
||||
case ".ppt":
|
||||
case ".pptx":
|
||||
return "ppt";
|
||||
default:
|
||||
return "stream";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a local file path (not a URL)
|
||||
*/
|
||||
function isLocalPath(urlOrPath: string): boolean {
|
||||
// Starts with / or ~ or drive letter (Windows)
|
||||
if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) {
|
||||
return true;
|
||||
}
|
||||
// Try to parse as URL - if it fails or has no protocol, it's likely a local path
|
||||
try {
|
||||
const url = new URL(urlOrPath);
|
||||
return url.protocol === "file:";
|
||||
} catch {
|
||||
return true; // Not a valid URL, treat as local path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload and send media (image or file) from URL, local path, or buffer
|
||||
*/
|
||||
export async function sendMediaFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
mediaUrl?: string;
|
||||
mediaBuffer?: Buffer;
|
||||
fileName?: string;
|
||||
replyToMessageId?: string;
|
||||
accountId?: string;
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params;
|
||||
|
||||
let buffer: Buffer;
|
||||
let name: string;
|
||||
|
||||
if (mediaBuffer) {
|
||||
buffer = mediaBuffer;
|
||||
name = fileName ?? "file";
|
||||
} else if (mediaUrl) {
|
||||
if (isLocalPath(mediaUrl)) {
|
||||
// Local file path - read directly
|
||||
const filePath = mediaUrl.startsWith("~")
|
||||
? mediaUrl.replace("~", process.env.HOME ?? "")
|
||||
: mediaUrl.replace("file://", "");
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Local file not found: ${filePath}`);
|
||||
}
|
||||
buffer = fs.readFileSync(filePath);
|
||||
name = fileName ?? path.basename(filePath);
|
||||
} else {
|
||||
// Remote URL - fetch
|
||||
const response = await fetch(mediaUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch media from URL: ${response.status}`);
|
||||
}
|
||||
buffer = Buffer.from(await response.arrayBuffer());
|
||||
name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Either mediaUrl or mediaBuffer must be provided");
|
||||
}
|
||||
|
||||
// Determine if it's an image based on extension
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext);
|
||||
|
||||
if (isImage) {
|
||||
const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
|
||||
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, accountId });
|
||||
} else {
|
||||
const fileType = detectFileType(name);
|
||||
const { fileKey } = await uploadFileFeishu({
|
||||
cfg,
|
||||
file: buffer,
|
||||
fileName: name,
|
||||
fileType,
|
||||
accountId,
|
||||
});
|
||||
return sendFileFeishu({ cfg, to, fileKey, replyToMessageId, accountId });
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { FeishuMessageEvent } from "./bot.js";
|
||||
|
||||
/**
|
||||
* Mention target user info
|
||||
*/
|
||||
export type MentionTarget = {
|
||||
openId: string;
|
||||
name: string;
|
||||
key: string; // Placeholder in original message, e.g. @_user_1
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract mention targets from message event (excluding the bot itself)
|
||||
*/
|
||||
export function extractMentionTargets(
|
||||
event: FeishuMessageEvent,
|
||||
botOpenId?: string,
|
||||
): MentionTarget[] {
|
||||
const mentions = event.message.mentions ?? [];
|
||||
|
||||
return mentions
|
||||
.filter((m) => {
|
||||
// Exclude the bot itself
|
||||
if (botOpenId && m.id.open_id === botOpenId) {
|
||||
return false;
|
||||
}
|
||||
// Must have open_id
|
||||
return !!m.id.open_id;
|
||||
})
|
||||
.map((m) => ({
|
||||
openId: m.id.open_id!,
|
||||
name: m.name,
|
||||
key: m.key,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message is a mention forward request
|
||||
* Rules:
|
||||
* - Group: message mentions bot + at least one other user
|
||||
* - DM: message mentions any user (no need to mention bot)
|
||||
*/
|
||||
export function isMentionForwardRequest(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
||||
const mentions = event.message.mentions ?? [];
|
||||
if (mentions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isDirectMessage = event.message.chat_type === "p2p";
|
||||
const hasOtherMention = mentions.some((m) => m.id.open_id !== botOpenId);
|
||||
|
||||
if (isDirectMessage) {
|
||||
// DM: trigger if any non-bot user is mentioned
|
||||
return hasOtherMention;
|
||||
} else {
|
||||
// Group: need to mention both bot and other users
|
||||
const hasBotMention = mentions.some((m) => m.id.open_id === botOpenId);
|
||||
return hasBotMention && hasOtherMention;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract message body from text (remove @ placeholders)
|
||||
*/
|
||||
export function extractMessageBody(text: string, allMentionKeys: string[]): string {
|
||||
let result = text;
|
||||
|
||||
// Remove all @ placeholders
|
||||
for (const key of allMentionKeys) {
|
||||
result = result.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "");
|
||||
}
|
||||
|
||||
return result.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format @mention for text message
|
||||
*/
|
||||
export function formatMentionForText(target: MentionTarget): string {
|
||||
return `<at user_id="${target.openId}">${target.name}</at>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format @everyone for text message
|
||||
*/
|
||||
export function formatMentionAllForText(): string {
|
||||
return `<at user_id="all">Everyone</at>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format @mention for card message (lark_md)
|
||||
*/
|
||||
export function formatMentionForCard(target: MentionTarget): string {
|
||||
return `<at id=${target.openId}></at>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format @everyone for card message
|
||||
*/
|
||||
export function formatMentionAllForCard(): string {
|
||||
return `<at id=all></at>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete message with @mentions (text format)
|
||||
*/
|
||||
export function buildMentionedMessage(targets: MentionTarget[], message: string): string {
|
||||
if (targets.length === 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const mentionParts = targets.map((t) => formatMentionForText(t));
|
||||
return `${mentionParts.join(" ")} ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build card content with @mentions (Markdown format)
|
||||
*/
|
||||
export function buildMentionedCardContent(targets: MentionTarget[], message: string): string {
|
||||
if (targets.length === 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const mentionParts = targets.map((t) => formatMentionForCard(t));
|
||||
return `${mentionParts.join(" ")} ${message}`;
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
||||
import * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { ResolvedFeishuAccount } from "./types.js";
|
||||
import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
|
||||
import { createFeishuWSClient, createEventDispatcher } from "./client.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
|
||||
export type MonitorFeishuOpts = {
|
||||
config?: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
// Per-account WebSocket clients and bot info
|
||||
const wsClients = new Map<string, Lark.WSClient>();
|
||||
const botOpenIds = new Map<string, string>();
|
||||
|
||||
async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await probeFeishu(account);
|
||||
return result.ok ? result.botOpenId : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor a single Feishu account.
|
||||
*/
|
||||
async function monitorSingleAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
account: ResolvedFeishuAccount;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const { cfg, account, runtime, abortSignal } = params;
|
||||
const { accountId } = account;
|
||||
const log = runtime?.log ?? console.log;
|
||||
const error = runtime?.error ?? console.error;
|
||||
|
||||
// Fetch bot open_id
|
||||
const botOpenId = await fetchBotOpenId(account);
|
||||
botOpenIds.set(accountId, botOpenId ?? "");
|
||||
log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
|
||||
|
||||
const connectionMode = account.config.connectionMode ?? "websocket";
|
||||
|
||||
if (connectionMode !== "websocket") {
|
||||
log(`feishu[${accountId}]: webhook mode not implemented in monitor`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`feishu[${accountId}]: starting WebSocket connection...`);
|
||||
|
||||
const wsClient = createFeishuWSClient(account);
|
||||
wsClients.set(accountId, wsClient);
|
||||
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
const eventDispatcher = createEventDispatcher(account);
|
||||
|
||||
eventDispatcher.register({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
try {
|
||||
const event = data as unknown as FeishuMessageEvent;
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
botOpenId: botOpenIds.get(accountId),
|
||||
runtime,
|
||||
chatHistories,
|
||||
accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
"im.message.message_read_v1": async () => {
|
||||
// Ignore read receipts
|
||||
},
|
||||
"im.chat.member.bot.added_v1": async (data) => {
|
||||
try {
|
||||
const event = data as unknown as FeishuBotAddedEvent;
|
||||
log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
|
||||
} catch (err) {
|
||||
error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
"im.chat.member.bot.deleted_v1": async (data) => {
|
||||
try {
|
||||
const event = data as unknown as { chat_id: string };
|
||||
log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`);
|
||||
} catch (err) {
|
||||
error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
wsClients.delete(accountId);
|
||||
botOpenIds.delete(accountId);
|
||||
};
|
||||
|
||||
const handleAbort = () => {
|
||||
log(`feishu[${accountId}]: abort signal received, stopping`);
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
||||
|
||||
try {
|
||||
void wsClient.start({ eventDispatcher });
|
||||
log(`feishu[${accountId}]: WebSocket client started`);
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
abortSignal?.removeEventListener("abort", handleAbort);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry: start monitoring for all enabled accounts.
|
||||
*/
|
||||
export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise<void> {
|
||||
const cfg = opts.config;
|
||||
if (!cfg) {
|
||||
throw new Error("Config is required for Feishu monitor");
|
||||
}
|
||||
|
||||
const log = opts.runtime?.log ?? console.log;
|
||||
|
||||
// If accountId is specified, only monitor that account
|
||||
if (opts.accountId) {
|
||||
const account = resolveFeishuAccount({ cfg, accountId: opts.accountId });
|
||||
if (!account.enabled || !account.configured) {
|
||||
throw new Error(`Feishu account "${opts.accountId}" not configured or disabled`);
|
||||
}
|
||||
return monitorSingleAccount({
|
||||
cfg,
|
||||
account,
|
||||
runtime: opts.runtime,
|
||||
abortSignal: opts.abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise, start all enabled accounts
|
||||
const accounts = listEnabledFeishuAccounts(cfg);
|
||||
if (accounts.length === 0) {
|
||||
throw new Error("No enabled Feishu accounts configured");
|
||||
}
|
||||
|
||||
log(
|
||||
`feishu: starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(", ")}`,
|
||||
);
|
||||
|
||||
// Start all accounts in parallel
|
||||
await Promise.all(
|
||||
accounts.map((account) =>
|
||||
monitorSingleAccount({
|
||||
cfg,
|
||||
account,
|
||||
runtime: opts.runtime,
|
||||
abortSignal: opts.abortSignal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring for a specific account or all accounts.
|
||||
*/
|
||||
export function stopFeishuMonitor(accountId?: string): void {
|
||||
if (accountId) {
|
||||
wsClients.delete(accountId);
|
||||
botOpenIds.delete(accountId);
|
||||
} else {
|
||||
wsClients.clear();
|
||||
botOpenIds.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,110 +1,28 @@
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
ClawdbotConfig,
|
||||
DmPolicy,
|
||||
OpenClawConfig,
|
||||
WizardPrompter,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
import { resolveFeishuCredentials } from "./accounts.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
listFeishuAccountIds,
|
||||
resolveDefaultFeishuAccountId,
|
||||
resolveFeishuAccount,
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
const channel = "feishu" as const;
|
||||
|
||||
function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
|
||||
function setFeishuDmPolicy(cfg: OpenClawConfig, policy: DmPolicy): OpenClawConfig {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function promptFeishuAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist Feishu DMs by open_id or user_id.",
|
||||
"You can find user open_id in Feishu admin console or via API.",
|
||||
"Examples:",
|
||||
"- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
].join("\n"),
|
||||
"Feishu allowlist",
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const entry = await params.prompter.text({
|
||||
message: "Feishu allowFrom (user open_ids)",
|
||||
placeholder: "ou_xxxxx, ou_yyyyy",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length === 0) {
|
||||
await params.prompter.note("Enter at least one user.", "Feishu allowlist");
|
||||
continue;
|
||||
}
|
||||
|
||||
const unique = [
|
||||
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts]),
|
||||
];
|
||||
return setFeishuAllowFrom(params.cfg, unique);
|
||||
}
|
||||
}
|
||||
|
||||
async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Go to Feishu Open Platform (open.feishu.cn)",
|
||||
"2) Create a self-built app",
|
||||
"3) Get App ID and App Secret from Credentials page",
|
||||
"4) Enable required permissions: im:message, im:chat, contact:user.base:readonly",
|
||||
"5) Publish the app or add it to a test group",
|
||||
"Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.",
|
||||
`Docs: ${formatDocsLink("/channels/feishu", "feishu")}`,
|
||||
].join("\n"),
|
||||
"Feishu credentials",
|
||||
);
|
||||
}
|
||||
|
||||
function setFeishuGroupPolicy(
|
||||
cfg: ClawdbotConfig,
|
||||
groupPolicy: "open" | "allowlist" | "disabled",
|
||||
): ClawdbotConfig {
|
||||
policy === "open" ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom) : undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
@@ -112,20 +30,111 @@ function setFeishuGroupPolicy(
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
groupPolicy,
|
||||
dmPolicy: policy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
|
||||
async function noteFeishuSetup(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"Create a Feishu/Lark app and enable Bot + Event Subscription (WebSocket).",
|
||||
"Copy the App ID and App Secret from the app credentials page.",
|
||||
'Lark (global): use open.larksuite.com and set domain="lark".',
|
||||
`Docs: ${formatDocsLink("/channels/feishu", "channels/feishu")}`,
|
||||
].join("\n"),
|
||||
"Feishu setup",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAllowEntry(entry: string): string {
|
||||
return entry.replace(/^(feishu|lark):/i, "").trim();
|
||||
}
|
||||
|
||||
function resolveDomainChoice(domain?: string | null): "feishu" | "lark" {
|
||||
const normalized = String(domain ?? "").toLowerCase();
|
||||
if (normalized.includes("lark") || normalized.includes("larksuite")) {
|
||||
return "lark";
|
||||
}
|
||||
return "feishu";
|
||||
}
|
||||
|
||||
async function promptFeishuAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string | null;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { cfg, prompter } = params;
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const existingAllowFrom = isDefault
|
||||
? (cfg.channels?.feishu?.allowFrom ?? [])
|
||||
: (cfg.channels?.feishu?.accounts?.[accountId]?.allowFrom ?? []);
|
||||
|
||||
const entry = await prompter.text({
|
||||
message: "Feishu allowFrom (open_id or union_id)",
|
||||
placeholder: "ou_xxx",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return "Required";
|
||||
}
|
||||
const entries = raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((item) => normalizeAllowEntry(item))
|
||||
.filter(Boolean);
|
||||
const invalid = entries.filter((item) => item !== "*" && !/^o[un]_[a-zA-Z0-9]+$/.test(item));
|
||||
if (invalid.length > 0) {
|
||||
return `Invalid Feishu ids: ${invalid.join(", ")}`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = String(entry)
|
||||
.split(/[\n,;]+/g)
|
||||
.map((item) => normalizeAllowEntry(item))
|
||||
.filter(Boolean);
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => normalizeAllowEntry(String(item))),
|
||||
...parsed,
|
||||
].filter(Boolean);
|
||||
const unique = Array.from(new Set(merged));
|
||||
|
||||
if (isDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
groupAllowFrom,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...cfg.channels?.feishu?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.feishu?.accounts?.[accountId],
|
||||
enabled: cfg.channels?.feishu?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -136,221 +145,134 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
channel,
|
||||
policyKey: "channels.feishu.dmPolicy",
|
||||
allowFromKey: "channels.feishu.allowFrom",
|
||||
getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing",
|
||||
getCurrent: (cfg) => cfg.channels?.feishu?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptFeishuAllowFrom,
|
||||
};
|
||||
|
||||
function updateFeishuConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
updates: { appId?: string; appSecret?: string; domain?: string; enabled?: boolean },
|
||||
): OpenClawConfig {
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const next = { ...cfg } as OpenClawConfig;
|
||||
const feishu = { ...next.channels?.feishu } as Record<string, unknown>;
|
||||
const accounts = feishu.accounts
|
||||
? { ...(feishu.accounts as Record<string, unknown>) }
|
||||
: undefined;
|
||||
|
||||
if (isDefault && !accounts) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...feishu,
|
||||
...updates,
|
||||
enabled: updates.enabled ?? true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedAccounts = accounts ?? {};
|
||||
const existing = (resolvedAccounts[accountId] as Record<string, unknown>) ?? {};
|
||||
resolvedAccounts[accountId] = {
|
||||
...existing,
|
||||
...updates,
|
||||
enabled: updates.enabled ?? true,
|
||||
};
|
||||
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...feishu,
|
||||
accounts: resolvedAccounts,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
dmPolicy,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const configured = Boolean(resolveFeishuCredentials(feishuCfg));
|
||||
|
||||
// Try to probe if configured
|
||||
let probeResult = null;
|
||||
if (configured && feishuCfg) {
|
||||
try {
|
||||
probeResult = await probeFeishu(feishuCfg);
|
||||
} catch {
|
||||
// Ignore probe errors
|
||||
}
|
||||
}
|
||||
|
||||
const statusLines: string[] = [];
|
||||
if (!configured) {
|
||||
statusLines.push("Feishu: needs app credentials");
|
||||
} else if (probeResult?.ok) {
|
||||
statusLines.push(
|
||||
`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`,
|
||||
);
|
||||
} else {
|
||||
statusLines.push("Feishu: configured (connection not verified)");
|
||||
}
|
||||
|
||||
const configured = listFeishuAccountIds(cfg).some((id) => {
|
||||
const acc = resolveFeishuAccount({ cfg, accountId: id });
|
||||
return acc.tokenSource !== "none";
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines,
|
||||
selectionHint: configured ? "configured" : "needs app creds",
|
||||
quickstartScore: configured ? 2 : 0,
|
||||
statusLines: [`Feishu: ${configured ? "configured" : "needs app credentials"}`],
|
||||
selectionHint: configured ? "configured" : "requires app credentials",
|
||||
quickstartScore: configured ? 1 : 10,
|
||||
};
|
||||
},
|
||||
|
||||
configure: async ({ cfg, prompter }) => {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const resolved = resolveFeishuCredentials(feishuCfg);
|
||||
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
|
||||
const canUseEnv = Boolean(
|
||||
!hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
|
||||
);
|
||||
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
||||
let next = cfg;
|
||||
let appId: string | null = null;
|
||||
let appSecret: string | null = null;
|
||||
const override = accountOverrides.feishu?.trim();
|
||||
const defaultId = resolveDefaultFeishuAccountId(next);
|
||||
let accountId = override ? normalizeAccountId(override) : defaultId;
|
||||
|
||||
if (!resolved) {
|
||||
await noteFeishuCredentialHelp(prompter);
|
||||
if (shouldPromptAccountIds && !override) {
|
||||
accountId = await promptAccountId({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label: "Feishu",
|
||||
currentId: accountId,
|
||||
listAccountIds: listFeishuAccountIds,
|
||||
defaultAccountId: defaultId,
|
||||
});
|
||||
}
|
||||
|
||||
if (canUseEnv) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
|
||||
await noteFeishuSetup(prompter);
|
||||
|
||||
const resolved = resolveFeishuAccount({ cfg: next, accountId });
|
||||
const domainChoice = await prompter.select({
|
||||
message: "Feishu domain",
|
||||
options: [
|
||||
{ value: "feishu", label: "Feishu (China) — open.feishu.cn" },
|
||||
{ value: "lark", label: "Lark (global) — open.larksuite.com" },
|
||||
],
|
||||
initialValue: resolveDomainChoice(resolved.config.domain),
|
||||
});
|
||||
const domain = domainChoice === "lark" ? "lark" : "feishu";
|
||||
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envAppId = process.env.FEISHU_APP_ID?.trim();
|
||||
const envSecret = process.env.FEISHU_APP_SECRET?.trim();
|
||||
if (isDefault && envAppId && envSecret) {
|
||||
const useEnv = await prompter.confirm({
|
||||
message: "FEISHU_APP_ID/FEISHU_APP_SECRET detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: { ...next.channels?.feishu, enabled: true },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App Secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigCreds) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Feishu credentials already configured. Keep them?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App Secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App Secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (appId && appSecret) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...next.channels?.feishu,
|
||||
enabled: true,
|
||||
appId,
|
||||
appSecret,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Test connection
|
||||
const testCfg = next.channels?.feishu as FeishuConfig;
|
||||
try {
|
||||
const probe = await probeFeishu(testCfg);
|
||||
if (probe.ok) {
|
||||
await prompter.note(
|
||||
`Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
|
||||
"Feishu connection test",
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
`Connection failed: ${probe.error ?? "unknown error"}`,
|
||||
"Feishu connection test",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test");
|
||||
if (useEnv) {
|
||||
next = updateFeishuConfig(next, accountId, { enabled: true, domain });
|
||||
return { cfg: next, accountId };
|
||||
}
|
||||
}
|
||||
const appId = String(
|
||||
await prompter.text({
|
||||
message: "Feishu App ID (cli_...)",
|
||||
initialValue: resolved.config.appId?.trim() || undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
|
||||
// Domain selection
|
||||
const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
|
||||
const domain = await prompter.select({
|
||||
message: "Which Feishu domain?",
|
||||
options: [
|
||||
{ value: "feishu", label: "Feishu (feishu.cn) - China" },
|
||||
{ value: "lark", label: "Lark (larksuite.com) - International" },
|
||||
],
|
||||
initialValue: currentDomain,
|
||||
});
|
||||
if (domain) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...next.channels?.feishu,
|
||||
domain: domain as "feishu" | "lark",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Feishu App Secret",
|
||||
initialValue: resolved.config.appSecret?.trim() || undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
|
||||
// Group policy
|
||||
const groupPolicy = await prompter.select({
|
||||
message: "Group chat policy",
|
||||
options: [
|
||||
{ value: "allowlist", label: "Allowlist - only respond in specific groups" },
|
||||
{ value: "open", label: "Open - respond in all groups (requires mention)" },
|
||||
{ value: "disabled", label: "Disabled - don't respond in groups" },
|
||||
],
|
||||
initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist",
|
||||
});
|
||||
if (groupPolicy) {
|
||||
next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled");
|
||||
}
|
||||
next = updateFeishuConfig(next, accountId, { appId, appSecret, domain, enabled: true });
|
||||
|
||||
// Group allowlist if needed
|
||||
if (groupPolicy === "allowlist") {
|
||||
const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: "Group chat allowlist (chat_ids)",
|
||||
placeholder: "oc_xxxxx, oc_yyyyy",
|
||||
initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
|
||||
});
|
||||
if (entry) {
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length > 0) {
|
||||
next = setFeishuGroupAllowFrom(next, parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
|
||||
dmPolicy,
|
||||
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: { ...cfg.channels?.feishu, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
||||
import { sendMediaFeishu } from "./media.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { sendMessageFeishu } from "./send.js";
|
||||
|
||||
export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
const result = await sendMessageFeishu({ cfg, to, text, accountId });
|
||||
return { channel: "feishu", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
||||
// Send text first if provided
|
||||
if (text?.trim()) {
|
||||
await sendMessageFeishu({ cfg, to, text, accountId });
|
||||
}
|
||||
|
||||
// Upload and send media if URL provided
|
||||
if (mediaUrl) {
|
||||
try {
|
||||
const result = await sendMediaFeishu({ cfg, to, mediaUrl, accountId });
|
||||
return { channel: "feishu", ...result };
|
||||
} catch (err) {
|
||||
// Log the error for debugging
|
||||
console.error(`[feishu] sendMediaFeishu failed:`, err);
|
||||
// Fallback to URL link if upload fails
|
||||
const fallbackText = `📎 ${mediaUrl}`;
|
||||
const result = await sendMessageFeishu({ cfg, to, text: fallbackText, accountId });
|
||||
return { channel: "feishu", ...result };
|
||||
}
|
||||
}
|
||||
|
||||
// No media URL, just return text result
|
||||
const result = await sendMessageFeishu({ cfg, to, text: text ?? "", accountId });
|
||||
return { channel: "feishu", ...result };
|
||||
},
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
const TokenType = Type.Union([
|
||||
Type.Literal("doc"),
|
||||
Type.Literal("docx"),
|
||||
Type.Literal("sheet"),
|
||||
Type.Literal("bitable"),
|
||||
Type.Literal("folder"),
|
||||
Type.Literal("file"),
|
||||
Type.Literal("wiki"),
|
||||
Type.Literal("mindnote"),
|
||||
]);
|
||||
|
||||
const MemberType = Type.Union([
|
||||
Type.Literal("email"),
|
||||
Type.Literal("openid"),
|
||||
Type.Literal("userid"),
|
||||
Type.Literal("unionid"),
|
||||
Type.Literal("openchat"),
|
||||
Type.Literal("opendepartmentid"),
|
||||
]);
|
||||
|
||||
const Permission = Type.Union([
|
||||
Type.Literal("view"),
|
||||
Type.Literal("edit"),
|
||||
Type.Literal("full_access"),
|
||||
]);
|
||||
|
||||
export const FeishuPermSchema = Type.Union([
|
||||
Type.Object({
|
||||
action: Type.Literal("list"),
|
||||
token: Type.String({ description: "File token" }),
|
||||
type: TokenType,
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("add"),
|
||||
token: Type.String({ description: "File token" }),
|
||||
type: TokenType,
|
||||
member_type: MemberType,
|
||||
member_id: Type.String({ description: "Member ID (email, open_id, user_id, etc.)" }),
|
||||
perm: Permission,
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("remove"),
|
||||
token: Type.String({ description: "File token" }),
|
||||
type: TokenType,
|
||||
member_type: MemberType,
|
||||
member_id: Type.String({ description: "Member ID to remove" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type FeishuPermParams = Static<typeof FeishuPermSchema>;
|
||||
@@ -1,173 +0,0 @@
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
|
||||
import { resolveToolsConfig } from "./tools-config.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
function json(data: unknown) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
type ListTokenType =
|
||||
| "doc"
|
||||
| "sheet"
|
||||
| "file"
|
||||
| "wiki"
|
||||
| "bitable"
|
||||
| "docx"
|
||||
| "mindnote"
|
||||
| "minutes"
|
||||
| "slides";
|
||||
type CreateTokenType =
|
||||
| "doc"
|
||||
| "sheet"
|
||||
| "file"
|
||||
| "wiki"
|
||||
| "bitable"
|
||||
| "docx"
|
||||
| "folder"
|
||||
| "mindnote"
|
||||
| "minutes"
|
||||
| "slides";
|
||||
type MemberType =
|
||||
| "email"
|
||||
| "openid"
|
||||
| "unionid"
|
||||
| "openchat"
|
||||
| "opendepartmentid"
|
||||
| "userid"
|
||||
| "groupid"
|
||||
| "wikispaceid";
|
||||
type PermType = "view" | "edit" | "full_access";
|
||||
|
||||
// ============ Actions ============
|
||||
|
||||
async function listMembers(client: Lark.Client, token: string, type: string) {
|
||||
const res = await client.drive.permissionMember.list({
|
||||
path: { token },
|
||||
params: { type: type as ListTokenType },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
members:
|
||||
res.data?.items?.map((m) => ({
|
||||
member_type: m.member_type,
|
||||
member_id: m.member_id,
|
||||
perm: m.perm,
|
||||
name: m.name,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function addMember(
|
||||
client: Lark.Client,
|
||||
token: string,
|
||||
type: string,
|
||||
memberType: string,
|
||||
memberId: string,
|
||||
perm: string,
|
||||
) {
|
||||
const res = await client.drive.permissionMember.create({
|
||||
path: { token },
|
||||
params: { type: type as CreateTokenType, need_notification: false },
|
||||
data: {
|
||||
member_type: memberType as MemberType,
|
||||
member_id: memberId,
|
||||
perm: perm as PermType,
|
||||
},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
member: res.data?.member,
|
||||
};
|
||||
}
|
||||
|
||||
async function removeMember(
|
||||
client: Lark.Client,
|
||||
token: string,
|
||||
type: string,
|
||||
memberType: string,
|
||||
memberId: string,
|
||||
) {
|
||||
const res = await client.drive.permissionMember.delete({
|
||||
path: { token, member_id: memberId },
|
||||
params: { type: type as CreateTokenType, member_type: memberType as MemberType },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Tool Registration ============
|
||||
|
||||
export function registerFeishuPermTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_perm: No config available, skipping perm tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = listEnabledFeishuAccounts(api.config);
|
||||
if (accounts.length === 0) {
|
||||
api.logger.debug?.("feishu_perm: No Feishu accounts configured, skipping perm tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAccount = accounts[0];
|
||||
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
||||
if (!toolsCfg.perm) {
|
||||
api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)");
|
||||
return;
|
||||
}
|
||||
|
||||
const getClient = () => createFeishuClient(firstAccount);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_perm",
|
||||
label: "Feishu Perm",
|
||||
description: "Feishu permission management. Actions: list, add, remove",
|
||||
parameters: FeishuPermSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuPermParams;
|
||||
try {
|
||||
const client = getClient();
|
||||
switch (p.action) {
|
||||
case "list":
|
||||
return json(await listMembers(client, p.token, p.type));
|
||||
case "add":
|
||||
return json(
|
||||
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
|
||||
);
|
||||
case "remove":
|
||||
return json(await removeMember(client, p.token, p.type, p.member_type, p.member_id));
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
||||
return json({ error: `Unknown action: ${(p as any).action}` });
|
||||
}
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_perm" },
|
||||
);
|
||||
|
||||
api.logger.info?.(`feishu_perm: Registered feishu_perm tool`);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig, FeishuGroupConfig } from "./types.js";
|
||||
|
||||
export type FeishuAllowlistMatch = {
|
||||
allowed: boolean;
|
||||
matchKey?: string;
|
||||
matchSource?: "wildcard" | "id" | "name";
|
||||
};
|
||||
|
||||
export function resolveFeishuAllowlistMatch(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
}): FeishuAllowlistMatch {
|
||||
const allowFrom = params.allowFrom
|
||||
.map((entry) => String(entry).trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
if (allowFrom.length === 0) {
|
||||
return { allowed: false };
|
||||
}
|
||||
if (allowFrom.includes("*")) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
|
||||
const senderId = params.senderId.toLowerCase();
|
||||
if (allowFrom.includes(senderId)) {
|
||||
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
||||
}
|
||||
|
||||
const senderName = params.senderName?.toLowerCase();
|
||||
if (senderName && allowFrom.includes(senderName)) {
|
||||
return { allowed: true, matchKey: senderName, matchSource: "name" };
|
||||
}
|
||||
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
export function resolveFeishuGroupConfig(params: {
|
||||
cfg?: FeishuConfig;
|
||||
groupId?: string | null;
|
||||
}): FeishuGroupConfig | undefined {
|
||||
const groups = params.cfg?.groups ?? {};
|
||||
const groupId = params.groupId?.trim();
|
||||
if (!groupId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const direct = groups[groupId];
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const lowered = groupId.toLowerCase();
|
||||
const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
|
||||
return matchKey ? groups[matchKey] : undefined;
|
||||
}
|
||||
|
||||
export function resolveFeishuGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const cfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!cfg) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const groupConfig = resolveFeishuGroupConfig({
|
||||
cfg,
|
||||
groupId: params.groupId,
|
||||
});
|
||||
|
||||
return groupConfig?.tools;
|
||||
}
|
||||
|
||||
export function isFeishuGroupAllowed(params: {
|
||||
groupPolicy: "open" | "allowlist" | "disabled";
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
}): boolean {
|
||||
const { groupPolicy } = params;
|
||||
if (groupPolicy === "disabled") {
|
||||
return false;
|
||||
}
|
||||
if (groupPolicy === "open") {
|
||||
return true;
|
||||
}
|
||||
return resolveFeishuAllowlistMatch(params).allowed;
|
||||
}
|
||||
|
||||
export function resolveFeishuReplyPolicy(params: {
|
||||
isDirectMessage: boolean;
|
||||
globalConfig?: FeishuConfig;
|
||||
groupConfig?: FeishuGroupConfig;
|
||||
}): { requireMention: boolean } {
|
||||
if (params.isDirectMessage) {
|
||||
return { requireMention: false };
|
||||
}
|
||||
|
||||
const requireMention =
|
||||
params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true;
|
||||
|
||||
return { requireMention };
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { FeishuProbeResult } from "./types.js";
|
||||
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
|
||||
|
||||
export async function probeFeishu(creds?: FeishuClientCredentials): Promise<FeishuProbeResult> {
|
||||
if (!creds?.appId || !creds?.appSecret) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "missing credentials (appId, appSecret)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(creds);
|
||||
// Use bot/v3/info API to get bot information
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK generic request method
|
||||
const response = await (client as any).request({
|
||||
method: "GET",
|
||||
url: "/open-apis/bot/v3/info",
|
||||
data: {},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: `API error: ${response.msg || `code ${response.code}`}`,
|
||||
};
|
||||
}
|
||||
|
||||
const bot = response.bot || response.data?.bot;
|
||||
return {
|
||||
ok: true,
|
||||
appId: creds.appId,
|
||||
botName: bot?.bot_name,
|
||||
botOpenId: bot?.open_id,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import {
|
||||
createReplyPrefixContext,
|
||||
createTypingCallbacks,
|
||||
logTypingFailure,
|
||||
type ClawdbotConfig,
|
||||
type RuntimeEnv,
|
||||
type ReplyPayload,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { MentionTarget } from "./mention.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js";
|
||||
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
||||
|
||||
/**
|
||||
* Detect if text contains markdown elements that benefit from card rendering.
|
||||
* Used by auto render mode.
|
||||
*/
|
||||
function shouldUseCard(text: string): boolean {
|
||||
// Code blocks (fenced)
|
||||
if (/```[\s\S]*?```/.test(text)) {
|
||||
return true;
|
||||
}
|
||||
// Tables (at least header + separator row with |)
|
||||
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export type CreateFeishuReplyDispatcherParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
runtime: RuntimeEnv;
|
||||
chatId: string;
|
||||
replyToMessageId?: string;
|
||||
/** Mention targets, will be auto-included in replies */
|
||||
mentionTargets?: MentionTarget[];
|
||||
/** Account ID for multi-account support */
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
||||
const core = getFeishuRuntime();
|
||||
const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
|
||||
|
||||
// Resolve account for config access
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
|
||||
const prefixContext = createReplyPrefixContext({
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
|
||||
// Feishu doesn't have a native typing indicator API.
|
||||
// We use message reactions as a typing indicator substitute.
|
||||
let typingState: TypingIndicatorState | null = null;
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: async () => {
|
||||
if (!replyToMessageId) {
|
||||
return;
|
||||
}
|
||||
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
|
||||
params.runtime.log?.(`feishu[${account.accountId}]: added typing indicator reaction`);
|
||||
},
|
||||
stop: async () => {
|
||||
if (!typingState) {
|
||||
return;
|
||||
}
|
||||
await removeTypingIndicator({ cfg, state: typingState, accountId });
|
||||
typingState = null;
|
||||
params.runtime.log?.(`feishu[${account.accountId}]: removed typing indicator reaction`);
|
||||
},
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => params.runtime.log?.(message),
|
||||
channel: "feishu",
|
||||
action: "start",
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
onStopError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => params.runtime.log?.(message),
|
||||
channel: "feishu",
|
||||
action: "stop",
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const textChunkLimit = core.channel.text.resolveTextChunkLimit({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
defaultLimit: 4000,
|
||||
});
|
||||
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
||||
onReplyStart: typingCallbacks.onReplyStart,
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
params.runtime.log?.(
|
||||
`feishu[${account.accountId}] deliver called: text=${payload.text?.slice(0, 100)}`,
|
||||
);
|
||||
const text = payload.text ?? "";
|
||||
if (!text.trim()) {
|
||||
params.runtime.log?.(`feishu[${account.accountId}] deliver: empty text, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check render mode: auto (default), raw, or card
|
||||
const feishuCfg = account.config;
|
||||
const renderMode = feishuCfg?.renderMode ?? "auto";
|
||||
|
||||
// Determine if we should use card for this message
|
||||
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
||||
|
||||
// Only include @mentions in the first chunk (avoid duplicate @s)
|
||||
let isFirstChunk = true;
|
||||
|
||||
if (useCard) {
|
||||
// Card mode: send as interactive card with markdown rendering
|
||||
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
|
||||
params.runtime.log?.(
|
||||
`feishu[${account.accountId}] deliver: sending ${chunks.length} card chunks to ${chatId}`,
|
||||
);
|
||||
for (const chunk of chunks) {
|
||||
await sendMarkdownCardFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
mentions: isFirstChunk ? mentionTargets : undefined,
|
||||
accountId,
|
||||
});
|
||||
isFirstChunk = false;
|
||||
}
|
||||
} else {
|
||||
// Raw mode: send as plain text with table conversion
|
||||
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
||||
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
|
||||
params.runtime.log?.(
|
||||
`feishu[${account.accountId}] deliver: sending ${chunks.length} text chunks to ${chatId}`,
|
||||
);
|
||||
for (const chunk of chunks) {
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
mentions: isFirstChunk ? mentionTargets : undefined,
|
||||
accountId,
|
||||
});
|
||||
isFirstChunk = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
params.runtime.error?.(
|
||||
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(err)}`,
|
||||
);
|
||||
typingCallbacks.onIdle?.();
|
||||
},
|
||||
onIdle: typingCallbacks.onIdle,
|
||||
});
|
||||
|
||||
return {
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
onModelSelected: prefixContext.onModelSelected,
|
||||
},
|
||||
markDispatchIdle,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setFeishuRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getFeishuRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Feishu runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import type { MentionTarget } from "./mention.js";
|
||||
import type { FeishuSendResult } from "./types.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
||||
|
||||
export type FeishuMessageInfo = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
senderId?: string;
|
||||
senderOpenId?: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
createTime?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a message by its ID.
|
||||
* Useful for fetching quoted/replied message content.
|
||||
*/
|
||||
export async function getMessageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuMessageInfo | null> {
|
||||
const { cfg, messageId, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
try {
|
||||
const response = (await client.im.message.get({
|
||||
path: { message_id: messageId },
|
||||
})) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
items?: Array<{
|
||||
message_id?: string;
|
||||
chat_id?: string;
|
||||
msg_type?: string;
|
||||
body?: { content?: string };
|
||||
sender?: {
|
||||
id?: string;
|
||||
id_type?: string;
|
||||
sender_type?: string;
|
||||
};
|
||||
create_time?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = response.data?.items?.[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse content based on message type
|
||||
let content = item.body?.content ?? "";
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (item.msg_type === "text" && parsed.text) {
|
||||
content = parsed.text;
|
||||
}
|
||||
} catch {
|
||||
// Keep raw content if parsing fails
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: item.message_id ?? messageId,
|
||||
chatId: item.chat_id ?? "",
|
||||
senderId: item.sender?.id,
|
||||
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
|
||||
content,
|
||||
contentType: item.msg_type ?? "text",
|
||||
createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type SendFeishuMessageParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
/** Mention target users */
|
||||
mentions?: MentionTarget[];
|
||||
/** Account ID (optional, uses default if not specified) */
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
function buildFeishuPostMessagePayload(params: { messageText: string }): {
|
||||
content: string;
|
||||
msgType: string;
|
||||
} {
|
||||
const { messageText } = params;
|
||||
return {
|
||||
content: JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [
|
||||
[
|
||||
{
|
||||
tag: "md",
|
||||
text: messageText,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
msgType: "post",
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendMessageFeishu(
|
||||
params: SendFeishuMessageParams,
|
||||
): Promise<FeishuSendResult> {
|
||||
const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
});
|
||||
|
||||
// Build message content (with @mention support)
|
||||
let rawText = text ?? "";
|
||||
if (mentions && mentions.length > 0) {
|
||||
rawText = buildMentionedMessage(mentions, rawText);
|
||||
}
|
||||
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(rawText, tableMode);
|
||||
|
||||
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
|
||||
|
||||
if (replyToMessageId) {
|
||||
const response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: msgType,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: msgType,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
export type SendFeishuCardParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
card: Record<string, unknown>;
|
||||
replyToMessageId?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
|
||||
const { cfg, to, card, replyToMessageId, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const content = JSON.stringify(card);
|
||||
|
||||
if (replyToMessageId) {
|
||||
const response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: "interactive",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: "interactive",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateCardFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
card: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
}): Promise<void> {
|
||||
const { cfg, messageId, card, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const content = JSON.stringify(card);
|
||||
|
||||
const response = await client.im.message.patch({
|
||||
path: { message_id: messageId },
|
||||
data: { content },
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu card update failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Feishu interactive card with markdown content.
|
||||
* Cards render markdown properly (code blocks, tables, links, etc.)
|
||||
*/
|
||||
export function buildMarkdownCard(text: string): Record<string, unknown> {
|
||||
return {
|
||||
config: {
|
||||
wide_screen_mode: true,
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message as a markdown card (interactive message).
|
||||
* This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
|
||||
*/
|
||||
export async function sendMarkdownCardFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
/** Mention target users */
|
||||
mentions?: MentionTarget[];
|
||||
accountId?: string;
|
||||
}): Promise<FeishuSendResult> {
|
||||
const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
|
||||
// Build message content (with @mention support)
|
||||
let cardText = text;
|
||||
if (mentions && mentions.length > 0) {
|
||||
cardText = buildMentionedCardContent(mentions, text);
|
||||
}
|
||||
const card = buildMarkdownCard(cardText);
|
||||
return sendCardFeishu({ cfg, to, card, replyToMessageId, accountId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing text message.
|
||||
* Note: Feishu only allows editing messages within 24 hours.
|
||||
*/
|
||||
export async function editMessageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
text: string;
|
||||
accountId?: string;
|
||||
}): Promise<void> {
|
||||
const { cfg, messageId, text, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
});
|
||||
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||
|
||||
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
|
||||
|
||||
const response = await client.im.message.update({
|
||||
path: { message_id: messageId },
|
||||
data: {
|
||||
msg_type: msgType,
|
||||
content,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { FeishuIdType } from "./types.js";
|
||||
|
||||
const CHAT_ID_PREFIX = "oc_";
|
||||
const OPEN_ID_PREFIX = "ou_";
|
||||
const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export function detectIdType(id: string): FeishuIdType | null {
|
||||
const trimmed = id.trim();
|
||||
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
||||
return "chat_id";
|
||||
}
|
||||
if (trimmed.startsWith(OPEN_ID_PREFIX)) {
|
||||
return "open_id";
|
||||
}
|
||||
if (USER_ID_REGEX.test(trimmed)) {
|
||||
return "user_id";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeFeishuTarget(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("chat:")) {
|
||||
return trimmed.slice("chat:".length).trim() || null;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return trimmed.slice("user:".length).trim() || null;
|
||||
}
|
||||
if (lowered.startsWith("open_id:")) {
|
||||
return trimmed.slice("open_id:".length).trim() || null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
|
||||
const trimmed = id.trim();
|
||||
if (type === "chat_id" || trimmed.startsWith(CHAT_ID_PREFIX)) {
|
||||
return `chat:${trimmed}`;
|
||||
}
|
||||
if (type === "open_id" || trimmed.startsWith(OPEN_ID_PREFIX)) {
|
||||
return `user:${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
|
||||
const trimmed = id.trim();
|
||||
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
||||
return "chat_id";
|
||||
}
|
||||
if (trimmed.startsWith(OPEN_ID_PREFIX)) {
|
||||
return "open_id";
|
||||
}
|
||||
return "open_id";
|
||||
}
|
||||
|
||||
export function looksLikeFeishuId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^(chat|user|open_id):/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
if (trimmed.startsWith(OPEN_ID_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { FeishuToolsConfig } from "./types.js";
|
||||
|
||||
/**
|
||||
* Default tool configuration.
|
||||
* - doc, wiki, drive, scopes: enabled by default
|
||||
* - perm: disabled by default (sensitive operation)
|
||||
*/
|
||||
export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
|
||||
doc: true,
|
||||
wiki: true,
|
||||
drive: true,
|
||||
perm: false,
|
||||
scopes: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve tools config with defaults.
|
||||
*/
|
||||
export function resolveToolsConfig(cfg?: FeishuToolsConfig): Required<FeishuToolsConfig> {
|
||||
return { ...DEFAULT_TOOLS_CONFIG, ...cfg };
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import type {
|
||||
FeishuConfigSchema,
|
||||
FeishuGroupSchema,
|
||||
FeishuAccountConfigSchema,
|
||||
z,
|
||||
} from "./config-schema.js";
|
||||
import type { MentionTarget } from "./mention.js";
|
||||
|
||||
export type FeishuConfig = z.infer<typeof FeishuConfigSchema>;
|
||||
export type FeishuGroupConfig = z.infer<typeof FeishuGroupSchema>;
|
||||
export type FeishuAccountConfig = z.infer<typeof FeishuAccountConfigSchema>;
|
||||
|
||||
export type FeishuDomain = "feishu" | "lark" | (string & {});
|
||||
export type FeishuConnectionMode = "websocket" | "webhook";
|
||||
|
||||
export type ResolvedFeishuAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
name?: string;
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
encryptKey?: string;
|
||||
verificationToken?: string;
|
||||
domain: FeishuDomain;
|
||||
/** Merged config (top-level defaults + account-specific overrides) */
|
||||
config: FeishuConfig;
|
||||
};
|
||||
|
||||
export type FeishuIdType = "open_id" | "user_id" | "union_id" | "chat_id";
|
||||
|
||||
export type FeishuMessageContext = {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
senderId: string;
|
||||
senderOpenId: string;
|
||||
senderName?: string;
|
||||
chatType: "p2p" | "group";
|
||||
mentionedBot: boolean;
|
||||
rootId?: string;
|
||||
parentId?: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
/** Mention forward targets (excluding the bot itself) */
|
||||
mentionTargets?: MentionTarget[];
|
||||
/** Extracted message body (after removing @ placeholders) */
|
||||
mentionMessageBody?: string;
|
||||
};
|
||||
|
||||
export type FeishuSendResult = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
};
|
||||
|
||||
export type FeishuProbeResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
appId?: string;
|
||||
botName?: string;
|
||||
botOpenId?: string;
|
||||
};
|
||||
|
||||
export type FeishuMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export type FeishuToolsConfig = {
|
||||
doc?: boolean;
|
||||
wiki?: boolean;
|
||||
drive?: boolean;
|
||||
perm?: boolean;
|
||||
scopes?: boolean;
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
|
||||
// Feishu emoji types for typing indicator
|
||||
// See: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
||||
// Full list: https://github.com/go-lark/lark/blob/main/emoji.go
|
||||
const TYPING_EMOJI = "Typing"; // Typing indicator emoji
|
||||
|
||||
export type TypingIndicatorState = {
|
||||
messageId: string;
|
||||
reactionId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a typing indicator (reaction) to a message
|
||||
*/
|
||||
export async function addTypingIndicator(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
accountId?: string;
|
||||
}): Promise<TypingIndicatorState> {
|
||||
const { cfg, messageId, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
return { messageId, reactionId: null };
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
try {
|
||||
const response = await client.im.messageReaction.create({
|
||||
path: { message_id: messageId },
|
||||
data: {
|
||||
reaction_type: { emoji_type: TYPING_EMOJI },
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
||||
const reactionId = (response as any)?.data?.reaction_id ?? null;
|
||||
return { messageId, reactionId };
|
||||
} catch (err) {
|
||||
// Silently fail - typing indicator is not critical
|
||||
console.log(`[feishu] failed to add typing indicator: ${err}`);
|
||||
return { messageId, reactionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a typing indicator (reaction) from a message
|
||||
*/
|
||||
export async function removeTypingIndicator(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
state: TypingIndicatorState;
|
||||
accountId?: string;
|
||||
}): Promise<void> {
|
||||
const { cfg, state, accountId } = params;
|
||||
if (!state.reactionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
try {
|
||||
await client.im.messageReaction.delete({
|
||||
path: {
|
||||
message_id: state.messageId,
|
||||
reaction_id: state.reactionId,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Silently fail - cleanup is not critical
|
||||
console.log(`[feishu] failed to remove typing indicator: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
export const FeishuWikiSchema = Type.Union([
|
||||
Type.Object({
|
||||
action: Type.Literal("spaces"),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("nodes"),
|
||||
space_id: Type.String({ description: "Knowledge space ID" }),
|
||||
parent_node_token: Type.Optional(
|
||||
Type.String({ description: "Parent node token (optional, omit for root)" }),
|
||||
),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("get"),
|
||||
token: Type.String({ description: "Wiki node token (from URL /wiki/XXX)" }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("search"),
|
||||
query: Type.String({ description: "Search query" }),
|
||||
space_id: Type.Optional(Type.String({ description: "Limit search to this space (optional)" })),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("create"),
|
||||
space_id: Type.String({ description: "Knowledge space ID" }),
|
||||
title: Type.String({ description: "Node title" }),
|
||||
obj_type: Type.Optional(
|
||||
Type.Union([Type.Literal("docx"), Type.Literal("sheet"), Type.Literal("bitable")], {
|
||||
description: "Object type (default: docx)",
|
||||
}),
|
||||
),
|
||||
parent_node_token: Type.Optional(
|
||||
Type.String({ description: "Parent node token (optional, omit for root)" }),
|
||||
),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("move"),
|
||||
space_id: Type.String({ description: "Source knowledge space ID" }),
|
||||
node_token: Type.String({ description: "Node token to move" }),
|
||||
target_space_id: Type.Optional(
|
||||
Type.String({ description: "Target space ID (optional, same space if omitted)" }),
|
||||
),
|
||||
target_parent_token: Type.Optional(
|
||||
Type.String({ description: "Target parent node token (optional, root if omitted)" }),
|
||||
),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("rename"),
|
||||
space_id: Type.String({ description: "Knowledge space ID" }),
|
||||
node_token: Type.String({ description: "Node token to rename" }),
|
||||
title: Type.String({ description: "New title" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type FeishuWikiParams = Static<typeof FeishuWikiSchema>;
|
||||
@@ -1,232 +0,0 @@
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { resolveToolsConfig } from "./tools-config.js";
|
||||
import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
function json(data: unknown) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
type ObjType = "doc" | "sheet" | "mindnote" | "bitable" | "file" | "docx" | "slides";
|
||||
|
||||
// ============ Actions ============
|
||||
|
||||
const WIKI_ACCESS_HINT =
|
||||
"To grant wiki access: Open wiki space → Settings → Members → Add the bot. " +
|
||||
"See: https://open.feishu.cn/document/server-docs/docs/wiki-v2/wiki-qa#a40ad4ca";
|
||||
|
||||
async function listSpaces(client: Lark.Client) {
|
||||
const res = await client.wiki.space.list({});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
const spaces =
|
||||
res.data?.items?.map((s) => ({
|
||||
space_id: s.space_id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
visibility: s.visibility,
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
spaces,
|
||||
...(spaces.length === 0 && { hint: WIKI_ACCESS_HINT }),
|
||||
};
|
||||
}
|
||||
|
||||
async function listNodes(client: Lark.Client, spaceId: string, parentNodeToken?: string) {
|
||||
const res = await client.wiki.spaceNode.list({
|
||||
path: { space_id: spaceId },
|
||||
params: { parent_node_token: parentNodeToken },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes:
|
||||
res.data?.items?.map((n) => ({
|
||||
node_token: n.node_token,
|
||||
obj_token: n.obj_token,
|
||||
obj_type: n.obj_type,
|
||||
title: n.title,
|
||||
has_child: n.has_child,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function getNode(client: Lark.Client, token: string) {
|
||||
const res = await client.wiki.space.getNode({
|
||||
params: { token },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
const node = res.data?.node;
|
||||
return {
|
||||
node_token: node?.node_token,
|
||||
space_id: node?.space_id,
|
||||
obj_token: node?.obj_token,
|
||||
obj_type: node?.obj_type,
|
||||
title: node?.title,
|
||||
parent_node_token: node?.parent_node_token,
|
||||
has_child: node?.has_child,
|
||||
creator: node?.creator,
|
||||
create_time: node?.node_create_time,
|
||||
};
|
||||
}
|
||||
|
||||
async function createNode(
|
||||
client: Lark.Client,
|
||||
spaceId: string,
|
||||
title: string,
|
||||
objType?: string,
|
||||
parentNodeToken?: string,
|
||||
) {
|
||||
const res = await client.wiki.spaceNode.create({
|
||||
path: { space_id: spaceId },
|
||||
data: {
|
||||
obj_type: (objType as ObjType) || "docx",
|
||||
node_type: "origin" as const,
|
||||
title,
|
||||
parent_node_token: parentNodeToken,
|
||||
},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
const node = res.data?.node;
|
||||
return {
|
||||
node_token: node?.node_token,
|
||||
obj_token: node?.obj_token,
|
||||
obj_type: node?.obj_type,
|
||||
title: node?.title,
|
||||
};
|
||||
}
|
||||
|
||||
async function moveNode(
|
||||
client: Lark.Client,
|
||||
spaceId: string,
|
||||
nodeToken: string,
|
||||
targetSpaceId?: string,
|
||||
targetParentToken?: string,
|
||||
) {
|
||||
const res = await client.wiki.spaceNode.move({
|
||||
path: { space_id: spaceId, node_token: nodeToken },
|
||||
data: {
|
||||
target_space_id: targetSpaceId || spaceId,
|
||||
target_parent_token: targetParentToken,
|
||||
},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
node_token: res.data?.node?.node_token,
|
||||
};
|
||||
}
|
||||
|
||||
async function renameNode(client: Lark.Client, spaceId: string, nodeToken: string, title: string) {
|
||||
const res = await client.wiki.spaceNode.updateTitle({
|
||||
path: { space_id: spaceId, node_token: nodeToken },
|
||||
data: { title },
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
node_token: nodeToken,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Tool Registration ============
|
||||
|
||||
export function registerFeishuWikiTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_wiki: No config available, skipping wiki tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = listEnabledFeishuAccounts(api.config);
|
||||
if (accounts.length === 0) {
|
||||
api.logger.debug?.("feishu_wiki: No Feishu accounts configured, skipping wiki tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAccount = accounts[0];
|
||||
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
||||
if (!toolsCfg.wiki) {
|
||||
api.logger.debug?.("feishu_wiki: wiki tool disabled in config");
|
||||
return;
|
||||
}
|
||||
|
||||
const getClient = () => createFeishuClient(firstAccount);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_wiki",
|
||||
label: "Feishu Wiki",
|
||||
description:
|
||||
"Feishu knowledge base operations. Actions: spaces, nodes, get, create, move, rename",
|
||||
parameters: FeishuWikiSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuWikiParams;
|
||||
try {
|
||||
const client = getClient();
|
||||
switch (p.action) {
|
||||
case "spaces":
|
||||
return json(await listSpaces(client));
|
||||
case "nodes":
|
||||
return json(await listNodes(client, p.space_id, p.parent_node_token));
|
||||
case "get":
|
||||
return json(await getNode(client, p.token));
|
||||
case "search":
|
||||
return json({
|
||||
error:
|
||||
"Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.",
|
||||
});
|
||||
case "create":
|
||||
return json(
|
||||
await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token),
|
||||
);
|
||||
case "move":
|
||||
return json(
|
||||
await moveNode(
|
||||
client,
|
||||
p.space_id,
|
||||
p.node_token,
|
||||
p.target_space_id,
|
||||
p.target_parent_token,
|
||||
),
|
||||
);
|
||||
case "rename":
|
||||
return json(await renameNode(client, p.space_id, p.node_token, p.title));
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
||||
return json({ error: `Unknown action: ${(p as any).action}` });
|
||||
}
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_wiki" },
|
||||
);
|
||||
|
||||
api.logger.info?.(`feishu_wiki: Registered feishu_wiki tool`);
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"@lancedb/lancedb": "^0.23.0",
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"openai": "^6.18.0"
|
||||
"openai": "^6.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
|
||||
@@ -93,12 +93,8 @@ export async function sendMessageNextcloudTalk(
|
||||
}
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
// Nextcloud Talk verifies signature against the extracted message text,
|
||||
// not the full JSON body. See ChecksumVerificationService.php:
|
||||
// hash_hmac('sha256', $random . $data, $secret)
|
||||
// where $data is the "message" parameter, not the raw request body.
|
||||
const { random, signature } = generateNextcloudTalkSignature({
|
||||
body: message,
|
||||
body: bodyStr,
|
||||
secret,
|
||||
});
|
||||
|
||||
@@ -187,9 +183,8 @@ export async function sendReactionNextcloudTalk(
|
||||
const normalizedToken = normalizeRoomToken(roomToken);
|
||||
|
||||
const body = JSON.stringify({ reaction });
|
||||
// Sign only the reaction string, not the full JSON body
|
||||
const { random, signature } = generateNextcloudTalkSignature({
|
||||
body: reaction,
|
||||
body,
|
||||
secret,
|
||||
});
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -98,8 +98,8 @@
|
||||
"ui:install": "node scripts/ui.js install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@aws-sdk/client-bedrock": "^3.984.0",
|
||||
"@agentclientprotocol/sdk": "0.14.0",
|
||||
"@aws-sdk/client-bedrock": "^3.983.0",
|
||||
"@buape/carbon": "0.14.0",
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
@@ -108,10 +108,10 @@
|
||||
"@larksuiteoapi/node-sdk": "^1.58.0",
|
||||
"@line/bot-sdk": "^10.6.0",
|
||||
"@lydell/node-pty": "1.2.0-beta.3",
|
||||
"@mariozechner/pi-agent-core": "0.52.6",
|
||||
"@mariozechner/pi-ai": "0.52.6",
|
||||
"@mariozechner/pi-coding-agent": "0.52.6",
|
||||
"@mariozechner/pi-tui": "0.52.6",
|
||||
"@mariozechner/pi-agent-core": "0.52.2",
|
||||
"@mariozechner/pi-ai": "0.52.2",
|
||||
"@mariozechner/pi-coding-agent": "0.52.2",
|
||||
"@mariozechner/pi-tui": "0.52.2",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"@slack/bolt": "^4.6.0",
|
||||
@@ -124,7 +124,7 @@
|
||||
"commander": "^14.0.3",
|
||||
"croner": "^10.0.1",
|
||||
"discord-api-types": "^0.38.38",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"file-type": "^21.3.0",
|
||||
"grammy": "^1.39.3",
|
||||
@@ -157,7 +157,7 @@
|
||||
"@lit/context": "^1.1.6",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
||||
672
pnpm-lock.yaml
generated
672
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
86
skills/qr-code/SKILL.md
Normal file
86
skills/qr-code/SKILL.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: qr-code
|
||||
description: Generate and read QR codes. Use when the user wants to create a QR code from text/URL, or decode/read a QR code from an image file. Supports PNG/JPG output and can read QR codes from screenshots or image files.
|
||||
---
|
||||
|
||||
# QR Code
|
||||
|
||||
Generate QR codes from text/URLs and decode QR codes from images.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Generate QR codes from any text, URL, or data
|
||||
- Customize QR code size and error correction level
|
||||
- Save as PNG or display in terminal
|
||||
- Read/decode QR codes from image files (PNG, JPG, etc.)
|
||||
- Read QR codes from screenshots
|
||||
|
||||
## Requirements
|
||||
|
||||
Install Python dependencies:
|
||||
|
||||
### For Generation
|
||||
|
||||
```bash
|
||||
pip install qrcode pillow
|
||||
```
|
||||
|
||||
### For Reading
|
||||
|
||||
```bash
|
||||
pip install pillow pyzbar
|
||||
```
|
||||
|
||||
On Windows, pyzbar requires Visual C++ Redistributable.
|
||||
On macOS: `brew install zbar`
|
||||
On Linux: `apt install libzbar0`
|
||||
|
||||
## Generate QR Code
|
||||
|
||||
```bash
|
||||
python scripts/qr_generate.py "https://example.com" output.png
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--size`: Box size in pixels (default: 10)
|
||||
- `--border`: Border size in boxes (default: 4)
|
||||
- `--error`: Error correction level L/M/Q/H (default: M)
|
||||
|
||||
Example with options:
|
||||
|
||||
```bash
|
||||
python scripts/qr_generate.py "Hello World" hello.png --size 15 --border 2
|
||||
```
|
||||
|
||||
## Read QR Code
|
||||
|
||||
```bash
|
||||
python scripts/qr_read.py image.png
|
||||
```
|
||||
|
||||
Returns the decoded text/URL from the QR code.
|
||||
|
||||
## Quick Examples
|
||||
|
||||
Generate QR for a URL:
|
||||
|
||||
```python
|
||||
import qrcode
|
||||
img = qrcode.make("https://openclaw.ai")
|
||||
img.save("openclaw.png")
|
||||
```
|
||||
|
||||
Read QR from image:
|
||||
|
||||
```python
|
||||
from pyzbar.pyzbar import decode
|
||||
from PIL import Image
|
||||
data = decode(Image.open("qr.png"))
|
||||
print(data[0].data.decode())
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/qr_generate.py` - Generate QR codes with customization options
|
||||
- `scripts/qr_read.py` - Decode QR codes from image files
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user