diff --git a/README.md b/README.md index ab3ca8ea..54de5f2c 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,6 @@ wiseflow 通过 `patches/` 目录对 openclaw 源码打补丁,每次运行 `ap | 补丁 | 说明 | 相关环境变量 | |------|------|-------------| -| `001-suppress-stale-reply-context` | 为 suppress-stale-reply 插件补充 inbound seq 上下文,防止过期消息触发回复 | 无 | | `002-disable-web-search-env-var` | 支持通过环境变量禁用 openclaw 内置 web search | `OPENCLAW_DISABLE_WEB_SEARCH=1` | | `003-act-field-validation` | 修复浏览器 act 动作的字段验证逻辑 | 无 | | `005-browser-timeout-env-var` | 支持通过环境变量自定义浏览器操作默认超时(原默认仅 20 秒,网络慢时容易中断) | `OPENCLAW_BROWSER_TIMEOUT_MS=60000` | @@ -258,9 +257,8 @@ wiseflow/ │ └── it-engineer/ # [built-in] IT Engineer(系统运维 + SEO 技术优化) │ └── skills/ # IT Engineer 专属技能(seo、session-logs 等) ├── skills/ # wiseflow 默认全局技能(smart-search / browser-guide / complex-task 等) -├── patches/ # wiseflow 基础补丁与插件(对所有 addon 生效) +├── patches/ # wiseflow 基础补丁(对所有 addon 生效) │ ├── *.patch # git 补丁(按序号顺序应用到 openclaw/) -│ ├── suppress-stale-reply/ # 抑制过期回复插件 │ └── overrides.sh # pnpm 依赖覆盖(如替换 playwright → patchright) ├── addons/ # addon 安装目录 │ ├── officials/ # [official] wiseflow 官方 addon diff --git a/addons/README.md b/addons/README.md index a1e383c7..fc1e8177 100644 --- a/addons/README.md +++ b/addons/README.md @@ -7,7 +7,7 @@ This directory's subdirectories are **git-ignored** — third-party addons are n wiseflow 采用两级扩展机制: -- **Base wiseflow**(`patches/` + `skills/`):每次 `apply-addons.sh` 运行时无条件应用,对所有 addon 和 crew 生效。包括代码补丁(`patches/*.patch`)、插件(`patches/suppress-stale-reply`)和默认全局技能(`skills/`)。 +- **Base wiseflow**(`patches/` + `skills/`):每次 `apply-addons.sh` 运行时无条件应用,对所有 addon 和 crew 生效。包括代码补丁(`patches/*.patch`)和默认全局技能(`skills/`)。 - **Addon**(`addons/*/`):在 base 之上叠加,提供额外全局技能(`skills/`)和 Crew 模板(`crew/`)。 > **注意**:addon 不包含 patches 层。如需对 openclaw 打补丁,请将 patch 放到项目根目录的 `patches/` 下,而非 addon 内部。 diff --git a/addons/officials/crew/business-developer/DENIED_SKILLS b/addons/officials/crew/business-developer/DENIED_SKILLS index a07e5c62..70b014df 100644 --- a/addons/officials/crew/business-developer/DENIED_SKILLS +++ b/addons/officials/crew/business-developer/DENIED_SKILLS @@ -1,8 +1,5 @@ github gh-issues coding-agent -# 内容运营专属技能(selfmedia-operator / designer 使用) login-manager -wxwork-drive -twitter-post -instagram-post +wx-mp-hunter diff --git a/addons/officials/crew/selfmedia-operator/skills/juejin-publish/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/juejin-publish/SKILL.md new file mode 100644 index 00000000..1779f9ba --- /dev/null +++ b/addons/officials/crew/selfmedia-operator/skills/juejin-publish/SKILL.md @@ -0,0 +1,234 @@ +--- +name: juejin-publish +description: 发布文章到掘金平台。使用浏览器自动化完成发布流程,包括导入 Markdown、处理本地图片、选择分类、添加标签、上传封面图、发布。当用户要求发布内容到掘金时触发。 +--- + +# 掘金文章发布 + +通过浏览器自动化将 Markdown 文章发布到掘金平台。 + +## 发布入口 + +``` +https://juejin.cn/creator/tool/import/self +``` + +## 核心原则(研发建议) + +### 1. 文件上传 + +- ✅ 使用 `setInputFiles` 注入文件路径,**不要模拟点击文件选择对话框** +- ✅ 上传后**必须等待平台完成 Markdown 解析渲染**,不能立即操作编辑器 +- ✅ 建议:上传后先 `snapshot` 确认编辑区内容已出现,再继续填写标题/标签 +- ✅ timeout 建议 **30000ms** + +### 2. DOM 稳定等待 + +平台拿到 Markdown 文件后会异步渲染到编辑器,这个过程 DOM 会抖动(内容先清空再注入)。 + +**在编辑区内容稳定前点击或输入会导致内容丢失或操作落在错误元素上。** + +**策略**:`snapshot` 两次,若两次内容一致则认为 DOM 稳定,再继续操作。 + +### 3. 发布确认 + +点击发布按钮后**不要立即导航或关闭**,平台会做后端校验(违禁词、封面生成等)。 + +- 等待跳转到文章详情页,或 +- 出现「发布成功」提示,再提取 URL +- timeout 建议 **30000ms** + +--- + +## 发布流程 + +### Step 1: 进入文章导入页面并上传文件 + +1. 打开 https://juejin.cn/creator/tool/import/self +2. 点击 **"创作工具 - 文章导入发布"** +3. **使用 setInputFiles 上传文件**(不要模拟点击文件选择对话框): + ```javascript + // 找到文件输入框 + const fileInput = document.querySelector('input[type="file"][accept*=".md"]'); + // 使用 browser 工具的 setInputFiles 注入文件路径 + ``` +4. **等待 DOM 稳定**: + - 上传后等待 3-5 秒,让平台完成 Markdown 解析 + - snapshot 两次确认编辑区内容一致 + - timeout 建议 30000ms + +### Step 2: 处理本地图片(重要) + +⚠️ **掘金 Markdown 导入不支持本地路径图片!** + +导入后需要手动处理图片: + +1. **删除本地图片链接**:在编辑器中找到 `![alt](/local/path/image.png)` 格式的图片,记录本地路径后删除 +2. **使用编辑器上传图片**:点击编辑器工具栏的 **"图片"** 按钮,手动上传对应的图片文件 + +**示例**: + +```markdown + +![ChatGPT vs Agent 对比](/home/user/campaign_assets/img-1/00.png) + + +![ChatGPT vs Agent 对比](https://p3-juejin.byteimg.com/xxx.png) +``` + +### Step 3: 检查并调整标题 + +确保标题输入框中的标题正确。如需修改: + +```javascript +document.querySelector('.title-input').value = '新标题'; +``` + +### Step 4: 点击发布按钮 + +点击编辑器右上角的 **"发布"** 按钮,打开发布设置弹窗。 + +### Step 5: 选择分类 + +掘金文章分类包括: +- 后端 +- 前端 +- Android +- iOS +- **人工智能**(AI 相关文章选择此项) +- 开发工具 +- 代码人生 +- 阅读 + +通过 JavaScript 选择分类: + +```javascript +const categories = document.querySelectorAll('.item'); +for (const cat of categories) { + if (cat.textContent.trim() === '人工智能') { + cat.click(); + break; + } +} +``` + +### Step 6: 添加标签 + +标签有助于文章被更多人发现。常用标签: + +| 主题 | 推荐标签 | +|------|----------| +| AI/Agent | AI, Agent, OpenAI, ChatGPT, AIGC | +| 前端 | JavaScript, TypeScript, React, Vue | +| 后端 | Node.js, Python, Go, Java | + +添加标签方法: + +```javascript +// 1. 点击标签输入框 +const tagInput = document.querySelectorAll('.byte-select__input')[0]; +tagInput.focus(); + +// 2. 输入标签名 +tagInput.value = 'AI'; +tagInput.dispatchEvent(new Event('input', { bubbles: true })); + +// 3. 从下拉选项中选择 +const options = document.querySelectorAll('.byte-select-option'); +for (const opt of options) { + if (opt.textContent.trim() === 'AI') { + opt.click(); + break; + } +} +``` + +### Step 7: 上传封面图 + +封面图用于首页信息流展示,建议尺寸 **192×128px**。 + +**封面图来源优先级**: +1. 文章中已有的配图(从 `campaign_assets/` 中选取) +2. 调用 `siliconflow-img-gen` 生成封面图 + +**上传方法**: + +```javascript +// 1. 点击上传封面按钮 +const uploadBtn = document.querySelector('button[class*="add_cover"]'); +if (uploadBtn) { + uploadBtn.click(); +} + +// 2. 使用 browser 工具的 upload action 上传图片 +// browser action=upload inputRef= paths=["/path/to/cover.png"] +``` + +**生成封面图示例**(如果需要): + +```bash +# 使用 siliconflow-img-gen 生成封面 +# 提示词应简洁,突出文章主题 +# 例如:"AI Agent digital worker, minimalist style, blue and white theme" +``` + +### Step 8: 确认发布并获取链接 + +点击「确定并发布」按钮后: + +1. **不要立即导航或关闭页面** +2. **等待后端校验完成**(违禁词检查、封面生成等) +3. **等待跳转到文章详情页**或出现「发布成功」提示 +4. timeout 建议 **30000ms** + +```javascript +const btns = document.querySelectorAll('button'); +for (const btn of btns) { + if (btn.textContent.includes('确定并发布')) { + btn.click(); + break; + } +} +``` + +### Step 9: 获取发布链接 + +发布成功后,页面会显示文章链接: + +```javascript +const link = document.querySelector('a[href*="/spost/"]'); +// 文章 URL: link.href +``` + +## 注意事项 + +1. **本地图片问题**:掘金 Markdown 导入不支持本地路径图片,必须手动删除后用编辑器重新上传 +2. **网络图片**:Markdown 中的网络图片 URL 可以正常显示 +3. **URL 格式**:使用超链接格式 `[text](url)` 而非直接暴露 URL +4. **分类必选**:必须选择一个分类才能发布 +5. **标签建议**:建议添加 2-3 个相关标签 +6. **封面图建议**:建议上传封面图,提升文章在首页信息流的展示效果 + +## 代码仓库地址格式 + +文章末尾的项目地址使用超链接格式: + +```markdown +> wiseflow 是一个开源的数字员工团队框架。[GitHub](https://github.com/TeamWiseFlow/wiseflow) 搜索 wiseflow(国内用户可在 [atomgit](https://atomgit.com/wiseflow/wiseflow) 搜索)。 +``` + +## 示例工作流 + +```bash +# 1. 打开 https://juejin.cn/creator/tool/import/self +# 2. 点击 "创作工具 - 文章导入发布" +# 3. 使用 setInputFiles 上传 Markdown 文件(不要模拟点击文件选择对话框) +# 4. 等待 DOM 稳定:snapshot 两次确认编辑区内容一致 +# 5. 处理本地图片:删除本地路径图片,用编辑器图片按钮重新上传 +# 6. 检查标题 +# 7. 点击发布 → 选择分类 → 添加标签 +# 8. 上传封面图 +# 9. 点击确定并发布 +# 10. 等待跳转到文章详情页或出现「发布成功」提示 +# 11. 记录发布链接到 published_articles_track.md +``` diff --git a/addons/officials/crew/selfmedia-operator/skills/toutiao-publish/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/toutiao-publish/SKILL.md new file mode 100644 index 00000000..0fb0eaaf --- /dev/null +++ b/addons/officials/crew/selfmedia-operator/skills/toutiao-publish/SKILL.md @@ -0,0 +1,124 @@ +--- +name: toutiao-publish +description: Publish Markdown articles to 今日头条 via docx document import. Converts Markdown (with local images embedded) to docx, then guides through Toutiao's "文档导入" upload flow. +metadata: + { + "openclaw": + { + "emoji": "📰", + "requires": { "bins": ["python3"] } + } + } +--- + +# Toutiao Publisher — 今日头条文档导入发布 + +将 Markdown 稿件转为 DOCX,通过今日头条"文档导入"功能上传发布,排版效果优于手动粘贴 HTML。 + +--- + +## Step 1:Markdown → DOCX + +```bash +python3 ./skills/toutiao-publish/scripts/md_to_docx.py \ + -f \ + -o /tmp/toutiao_article.docx +``` + +**脚本行为:** +- 自动将 Markdown 中的本地图片路径嵌入 Word 文档(确保上传后图片不丢失) +- 远程图片(http/https)以占位文本代替,需上传后在编辑器补图 +- 转换完成后检查文件大小:**超过 15 MB 自动删除图片**,并提示在头条编辑器中手动补图 + +**前置依赖:** + +```bash +pip install python-docx +``` + +> 已包含在 addon 的 `requirements.txt` 中,初始化时会自动安装。 + +**Frontmatter 支持(可选,建议提供):** + +```yaml +--- +title: 文章标题 +author: 作者名 +--- +``` + +--- + +## Step 2:浏览器上传(文档导入流程) + +使用 browser 工具,按以下步骤操作: + +### 2-1 进入创作页面 + +打开今日头条创作平台:`https://mp.toutiao.com/profile_v4/graphic/publish` + +### 2-2 选择文档导入 + +页面加载后,找到 **"文档导入"** 入口(通常在编辑器工具栏右侧或顶部菜单)。点击后弹出上传对话框。 + +> 如果未看到"文档导入",可尝试路径:顶部"发文章" → 编辑器工具栏 → 点击"..."更多选项 → "文档导入" + +### 2-3 上传 DOCX + +**使用 `setInputFiles` 注入文件路径,不要模拟点击文件选择对话框**,直接将文件路径注入 `` 元素: + +``` +setInputFiles("input[type=file]", "/tmp/toutiao_article.docx") +``` + +上传后**不要立即操作编辑器**,平台需要异步解析文档并渲染到编辑器(DOM 会先清空再注入内容)。 + +**等待编辑区 DOM 稳定的策略:** + +1. 上传后先 `snapshot`,检查编辑区是否已出现文章内容(`timeout: 30000ms`) +2. 若内容已出现,再 `snapshot` 第二次——**两次 snapshot 内容一致**,才认为 DOM 稳定 +3. DOM 稳定后再继续填写标题、标签等操作,否则内容可能丢失或操作落在错误元素上 + +### 2-4 编辑器内检查 + +DOM 稳定后,在编辑器内确认: + +- 标题已正确填入(若未填入,手动输入) +- 正文排版基本正确(段落、标题、列表) +- 图片显示正常(若 Step 1 提示"已删除图片",此时手动在编辑器补图) + +### 2-5 设置封面与发布选项 + +在编辑器右侧或底部依次完成: + +1. **展示封面**:点击"添加封面",选择文章内图片或上传单独封面图(建议 3:2 或 16:9 比例) +2. **声明首发**:勾选"声明原创/首发"复选框 +3. **引用 AI**:勾选"AI 辅助创作"声明 + +### 2-6 预览并发布 + +点击 **"预览发布"** 按钮,确认预览页内容无误后点击 **"确认发布"**。 + +**点击发布后不要立即导航或关闭页面**,平台会进行后端校验(违禁词检测、封面生成等)。 +等待跳转到文章详情页或出现"发布成功"提示后,再提取文章 URL(`timeout: 30000ms`)。 + +--- + +## Agent 行为约束 + +1. Step 1 脚本执行完成后,**必须先检查输出中是否有"超过 15 MB"提示**,若有,告知用户图片需手动补 +2. 上传文件使用 `setInputFiles` 注入路径,**不模拟点击文件选择对话框**;注入后等待 DOM 稳定(双 snapshot 确认)再继续 +3. 发布完成前需确认"声明首发"和"引用 AI"两项均已勾选 +4. 点击发布后等待跳转到文章详情页或出现"发布成功"提示,再提取 URL,**不要提前关闭或导航** + +--- + +## Error Handling + +| 问题 | 处理方式 | +|------|---------| +| `缺少依赖 python-docx` | 运行 `pip install python-docx` 后重试 | +| 文件不存在 | 确认 Markdown 文件路径正确 | +| 上传后图片丢失 | 图片超出 15MB 限制被删除,在编辑器手动补图 | +| 文档导入入口找不到 | 尝试刷新页面或检查账号是否已开通创作者权限 | +| 上传解析失败 | 确认 docx 格式正常(本地用 WPS/Word 打开验证) | diff --git a/addons/officials/crew/selfmedia-operator/skills/toutiao-publish/scripts/md_to_docx.py b/addons/officials/crew/selfmedia-operator/skills/toutiao-publish/scripts/md_to_docx.py new file mode 100644 index 00000000..5da0c5e5 --- /dev/null +++ b/addons/officials/crew/selfmedia-operator/skills/toutiao-publish/scripts/md_to_docx.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" +md_to_docx.py — Convert Markdown to DOCX with embedded local images. + +Usage: + python md_to_docx.py -f article.md -o /tmp/article.docx + +Rules: + - Local images are embedded into the Word document. + - If the resulting docx exceeds 15 MB, images are stripped and the file is saved again. + - Remote images (http/https) are skipped with a placeholder. +""" + +import argparse +import os +import re +import shutil +import sys +import tempfile +import zipfile +from pathlib import Path + + +def parse_frontmatter(text: str) -> tuple[dict, str]: + """Return (meta dict, body text without frontmatter).""" + meta: dict = {} + fm_match = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL) + if not fm_match: + return meta, text + for line in fm_match.group(1).splitlines(): + kv = re.match(r"^(\w+):\s*(.+)", line) + if kv: + meta[kv.group(1)] = kv.group(2).strip().strip("\"'") + return meta, text[fm_match.end():] + + +def add_inline_runs(paragraph, text: str, base_dir: Path) -> None: + """Add text runs with bold/italic/inline-code to a paragraph. + Inline images inside a paragraph are appended as separate runs.""" + from docx.shared import Pt + + # Strip markdown links → keep label text + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) + + # Split on bold/italic markers (greedy-safe patterns) + token_re = re.compile( + r"(\*\*\*[^*]+?\*\*\*" + r"|\*\*[^*]+?\*\*" + r"|__[^_]+?__" + r"|\*[^*]+?\*" + r"|_[^_]+?_" + r"|`[^`]+?`)" + ) + pos = 0 + for m in token_re.finditer(text): + # plain text before match + if m.start() > pos: + paragraph.add_run(text[pos : m.start()]) + token = m.group(0) + if token.startswith("***") or token.startswith("___"): + run = paragraph.add_run(token[3:-3]) + run.bold = True + run.italic = True + elif token.startswith("**") or token.startswith("__"): + run = paragraph.add_run(token[2:-2]) + run.bold = True + elif token.startswith("*") or token.startswith("_"): + run = paragraph.add_run(token[1:-1]) + run.italic = True + elif token.startswith("`"): + run = paragraph.add_run(token[1:-1]) + run.font.name = "Courier New" + run.font.size = Pt(10) + pos = m.end() + # remaining plain text + if pos < len(text): + paragraph.add_run(text[pos:]) + + +def try_add_image(doc, img_path: Path, width_inches: float = 5.5) -> bool: + """Add image paragraph to doc. Returns True on success.""" + from docx.shared import Inches + + if not img_path.exists(): + doc.add_paragraph(f"[图片未找到: {img_path.name}]") + return False + try: + doc.add_picture(str(img_path), width=Inches(width_inches)) + return True + except Exception as exc: + doc.add_paragraph(f"[图片嵌入失败: {img_path.name} — {exc}]") + return False + + +def convert(md_path: Path, out_path: Path) -> None: + from docx import Document + from docx.shared import Pt + + text = md_path.read_text(encoding="utf-8") + base_dir = md_path.parent + meta, body = parse_frontmatter(text) + + doc = Document() + + # Title from frontmatter + if meta.get("title"): + doc.add_heading(meta["title"], level=0) + + lines = body.splitlines() + i = 0 + in_code_block = False + code_lines: list[str] = [] + code_lang = "" + + while i < len(lines): + line = lines[i] + + # ── Code block ──────────────────────────────────────────────────────── + if line.startswith("```"): + if not in_code_block: + in_code_block = True + code_lang = line[3:].strip() + code_lines = [] + else: + in_code_block = False + p = doc.add_paragraph("\n".join(code_lines), style="No Spacing") + if p.runs: + p.runs[0].font.name = "Courier New" + p.runs[0].font.size = Pt(10) + i += 1 + continue + + if in_code_block: + code_lines.append(line) + i += 1 + continue + + # ── Heading ─────────────────────────────────────────────────────────── + h_match = re.match(r"^(#{1,6})\s+(.+)", line) + if h_match: + doc.add_heading(h_match.group(2).strip(), level=len(h_match.group(1))) + i += 1 + continue + + # ── Standalone image (whole line) ───────────────────────────────────── + img_match = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$", line.strip()) + if img_match: + src = img_match.group(2) + if not src.startswith("http"): + try_add_image(doc, base_dir / src) + else: + doc.add_paragraph(f"[远程图片: {src}]") + i += 1 + continue + + # ── Horizontal rule ─────────────────────────────────────────────────── + if re.match(r"^[-*_]{3,}\s*$", line): + doc.add_paragraph("─" * 40) + i += 1 + continue + + # ── Unordered list ──────────────────────────────────────────────────── + ul_match = re.match(r"^[\-\*\+]\s+(.+)", line) + if ul_match: + p = doc.add_paragraph(style="List Bullet") + add_inline_runs(p, ul_match.group(1), base_dir) + i += 1 + continue + + # ── Ordered list ────────────────────────────────────────────────────── + ol_match = re.match(r"^\d+\.\s+(.+)", line) + if ol_match: + p = doc.add_paragraph(style="List Number") + add_inline_runs(p, ol_match.group(1), base_dir) + i += 1 + continue + + # ── Blockquote ──────────────────────────────────────────────────────── + bq_match = re.match(r"^>\s+(.*)", line) + if bq_match: + p = doc.add_paragraph(style="Quote") + add_inline_runs(p, bq_match.group(1), base_dir) + i += 1 + continue + + # ── Empty line ──────────────────────────────────────────────────────── + if not line.strip(): + i += 1 + continue + + # ── Normal paragraph ────────────────────────────────────────────────── + p = doc.add_paragraph() + add_inline_runs(p, line, base_dir) + i += 1 + + doc.save(str(out_path)) + + +def strip_images_from_docx(docx_path: Path) -> None: + """Remove all embedded images from a docx to reduce file size.""" + tmp = docx_path.with_suffix(".tmp.docx") + shutil.copy2(docx_path, tmp) + + # Step 1: rebuild zip without word/media/* files + with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as fh: + stage1 = Path(fh.name) + + with zipfile.ZipFile(tmp, "r") as zin, zipfile.ZipFile( + stage1, "w", zipfile.ZIP_DEFLATED + ) as zout: + for item in zin.infolist(): + if item.filename.startswith("word/media/"): + continue + zout.writestr(item, zin.read(item.filename)) + + # Step 2: strip blocks from document.xml + with zipfile.ZipFile(stage1, "r") as z: + doc_xml = z.read("word/document.xml").decode("utf-8") + doc_xml = re.sub(r".*?", "", doc_xml, flags=re.DOTALL) + + with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as fh: + stage2 = Path(fh.name) + + with zipfile.ZipFile(stage1, "r") as zin, zipfile.ZipFile( + stage2, "w", zipfile.ZIP_DEFLATED + ) as zout: + for item in zin.infolist(): + if item.filename == "word/document.xml": + zout.writestr(item, doc_xml.encode("utf-8")) + else: + zout.writestr(item, zin.read(item.filename)) + + shutil.move(str(stage2), str(docx_path)) + tmp.unlink(missing_ok=True) + stage1.unlink(missing_ok=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Convert Markdown to DOCX") + parser.add_argument("-f", "--file", required=True, help="Input Markdown file") + parser.add_argument("-o", "--output", required=True, help="Output DOCX path") + args = parser.parse_args() + + md_path = Path(args.file).resolve() + out_path = Path(args.output).resolve() + + if not md_path.exists(): + print(f"ERROR: 文件不存在: {md_path}", file=sys.stderr) + return 1 + + try: + from docx import Document # noqa: F401 — early import check + except ImportError: + print( + "ERROR: 缺少依赖 python-docx。请运行:pip install python-docx", + file=sys.stderr, + ) + return 1 + + print(f">>> 转换: {md_path.name} → {out_path.name}") + convert(md_path, out_path) + + size_mb = out_path.stat().st_size / (1024 * 1024) + print(f">>> 文件大小: {size_mb:.1f} MB") + + if size_mb > 15: + print(">>> 超过 15 MB,移除图片后重新保存...") + strip_images_from_docx(out_path) + size_mb = out_path.stat().st_size / (1024 * 1024) + print(f">>> 移除图片后大小: {size_mb:.1f} MB(图片已删除,请在头条编辑器中手动补图)") + + print(f">>> 完成: {out_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/addons/officials/crew/selfmedia-operator/skills/wenyan-publisher/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/wenyan-publisher/SKILL.md deleted file mode 100644 index 0fb4dbeb..00000000 --- a/addons/officials/crew/selfmedia-operator/skills/wenyan-publisher/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: wenyan-publisher -description: Render Markdown to platform-optimized HTML and publish to Zhihu, Toutiao/Juejin, or Medium via browser automation. Uses browser-guide skill for the publishing step. -metadata: - { - "openclaw": - { - "emoji": "🌐", - "requires": { "bins": ["node"] }, - }, - } ---- - -# Wenyan Publisher — 多平台发布 - -将 Markdown 渲染为目标平台格式,再通过浏览器自动化完成发布。 - -**支持平台**: `zhihu`(知乎)| `toutiao`(今日头条,含 juejin 掘金)| `medium` - - ---- - -## Step 1:Render Markdown → 平台 HTML - -```bash -node ./skills/wenyan-publisher/scripts/render.mjs -f article.md --platform -o /tmp/output.html -``` - -| 平台 | 命令示例 | -|------|---------| -| 知乎 | `node ./skills/wenyan-publisher/scripts/render.mjs -f article.md --platform zhihu -o /tmp/zhihu.html` | -| 今日头条 / 掘金 | `node ./skills/wenyan-publisher/scripts/render.mjs -f article.md --platform toutiao -o /tmp/toutiao.html` | -| Medium | `node ./skills/wenyan-publisher/scripts/render.mjs -f article.md --platform medium -o /tmp/medium.html` | - ---- - -## Step 2:通过 browser-guide 发布 - -使用 **browser-guide** skill 打开目标平台编辑器,将 Step 1 输出的 HTML 粘贴后发布: - -| 平台 | 编辑器入口 | 操作要点 | -|------|-----------|---------| -| `zhihu` | `zhihu.com/p/new` | 切换到富文本模式,粘贴 HTML | -| `toutiao` | `mp.toutiao.com` | 选择图文发布,粘贴 HTML | -| `juejin` | `juejin.cn/editor/drafts/new` | 切换富文本模式,粘贴 HTML(与 toutiao 用同一渲染结果)| -| `medium` | `medium.com/new-story` | 切换 HTML 模式,粘贴内容 | - ---- - -## Render 参数 - -| 参数 | 默认值 | 说明 | -|------|-------|------| -| `-f, --file ` | 必填 | Markdown 文件路径 | -| `--platform ` | `wechat` | `zhihu` \| `toutiao`(juejin 同此)\| `medium` | -| `-t, --theme ` | `default` | 排版主题 ID | -| `-h, --highlight ` | `solarized-light` | 代码高亮主题 | -| `-c, --custom-theme ` | — | 自定义主题 CSS | -| `--no-mac-style` | — | 禁用代码块 Mac 风格 | -| `--no-footnote` | — | 禁用链接转脚注 | -| `-o, --output ` | stdout | 输出到文件(推荐指定,便于浏览器读取) | - ---- - -## 平台差异说明 - -| 平台 | 特殊处理 | -|------|---------| -| `zhihu` | MathJax 公式 → `formula` | -| `toutiao` / `juejin` | MathJax SVG → inline data:image/svg+xml(掘金与今日头条共用此处理逻辑)| -| `medium` | 引用/代码块/表格/数学公式标准化为纯文本 | - -> 文章不含数学公式时,三个平台输出的 HTML 差异很小。 - ---- - -## Error Handling - -| 错误 | 处理方式 | -|------|---------| -| `Cannot find module '@wenyan-md/core'` | 在 `./skills/wenyan-publisher/` 下运行 `npm install` | -| `--file (-f) is required` | 提供 Markdown 文件路径 | -| `unsupported platform` | 使用 `zhihu`、`toutiao`、`medium` 之一(juejin 请用 `toutiao`)| diff --git a/addons/officials/crew/selfmedia-operator/skills/wenyan-publisher/package.json b/addons/officials/crew/selfmedia-operator/skills/wenyan-publisher/package.json deleted file mode 100644 index 1f534187..00000000 --- a/addons/officials/crew/selfmedia-operator/skills/wenyan-publisher/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "wenyan-publisher", - "version": "1.0.0", - "type": "module", - "private": true, - "description": "Multi-platform Markdown renderer: WeChat / Zhihu / Toutiao / Medium", - "scripts": { - "render": "node scripts/render.mjs" - }, - "dependencies": { - "@wenyan-md/core": "^2.0.8", - "jsdom": "^27.4.0" - } -} diff --git a/addons/officials/crew/selfmedia-operator/skills/wenyan-publisher/scripts/render.mjs b/addons/officials/crew/selfmedia-operator/skills/wenyan-publisher/scripts/render.mjs deleted file mode 100644 index 0c6538b4..00000000 --- a/addons/officials/crew/selfmedia-operator/skills/wenyan-publisher/scripts/render.mjs +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env node -/** - * render.mjs — Multi-platform Markdown renderer - * - * Renders a Markdown file to platform-optimized HTML using @wenyan-md/core. - * - * Usage: - * node render.mjs -f article.md [options] - * - * Options: - * -f, --file Markdown file (required, or use stdin) - * --platform wechat | zhihu | toutiao | medium (default: wechat) - * -t, --theme Rendering theme ID (default: default) - * -h, --highlight Code highlight theme (default: solarized-light) - * --no-mac-style Disable Mac-style code blocks - * --no-footnote Disable link-to-footnote conversion - * -o, --output Write HTML to file instead of stdout - * --help Show this help - * - * Output: processed HTML on stdout (or to --output file) - * Errors: to stderr, exit code 1 - * - * Platform post-processing: - * wechat — themed HTML, ready to paste into WeChat GZH editor - * zhihu — MathJax formulas → formula - * toutiao — MathJax SVGs → inline data:image/svg+xml img - * medium — blockquote/code/table/math normalization - * - * Install dependencies first: - * npm install (in skills/wenyan-render/) - */ - -import { parseArgs } from 'node:util'; -import { readFile, writeFile } from 'node:fs/promises'; -import { JSDOM } from 'jsdom'; -import { renderStyledContent } from '@wenyan-md/core/wrapper'; -import { getContentForZhihu, getContentForToutiao, getContentForMedium } from '@wenyan-md/core'; - -// ── Argument parsing ─────────────────────────────────────────────────────────── - -const PLATFORMS = ['wechat', 'zhihu', 'toutiao', 'medium']; - -const { values } = parseArgs({ - options: { - file: { type: 'string', short: 'f' }, - platform: { type: 'string', default: 'wechat' }, - theme: { type: 'string', short: 't', default: 'default' }, - highlight: { type: 'string', short: 'h', default: 'solarized-light' }, - 'custom-theme': { type: 'string', short: 'c' }, - 'no-mac-style': { type: 'boolean', default: false }, - 'no-footnote': { type: 'boolean', default: false }, - output: { type: 'string', short: 'o' }, - help: { type: 'boolean', default: false }, - }, - allowPositionals: true, - strict: false, -}); - -if (values.help) { - console.log(`Usage: node render.mjs -f [options] - -Options: - -f, --file Markdown file path (required) - --platform ${PLATFORMS.join(' | ')} (default: wechat) - -t, --theme Theme ID (default: default) - -h, --highlight Code highlight theme (default: solarized-light) - -c, --custom-theme Custom theme CSS file path - --no-mac-style Disable Mac-style code blocks - --no-footnote Disable link-to-footnote conversion - -o, --output Write to file instead of stdout`); - process.exit(0); -} - -if (!values.file) { - console.error('Error: --file (-f) is required'); - process.exit(1); -} - -if (!PLATFORMS.includes(values.platform)) { - console.error(`Error: unsupported platform "${values.platform}". Supported: ${PLATFORMS.join(', ')}`); - process.exit(1); -} - -// ── Main ─────────────────────────────────────────────────────────────────────── - -try { - const markdown = await readFile(values.file, 'utf-8'); - - // Step 1: render Markdown → styled HTML (WeChat theme applied) - const styled = await renderStyledContent(markdown, { - theme: values.theme, - highlight: values.highlight, - customTheme: values['custom-theme'], - macStyle: !values['no-mac-style'], - footnote: !values['no-footnote'], - }); - - let html; - - if (values.platform === 'wechat') { - html = styled.content; - } else { - // Step 2: apply platform-specific post-processing via jsdom - const dom = new JSDOM(`
${styled.content}
`); - const element = dom.window.document.getElementById('wenyan'); - - switch (values.platform) { - case 'zhihu': - html = getContentForZhihu(element); - break; - case 'toutiao': - html = getContentForToutiao(element); - break; - case 'medium': - html = getContentForMedium(element); - break; - } - } - - if (values.output) { - await writeFile(values.output, html, 'utf-8'); - console.error(`Output written to: ${values.output}`); - } else { - process.stdout.write(html); - } -} catch (err) { - console.error('Error:', err.message || err); - process.exit(1); -} diff --git a/addons/officials/crew/selfmedia-operator/skills/youtube-upload/SKILL.md b/addons/officials/crew/selfmedia-operator/skills/youtube-upload/SKILL.md index c2a4e1b2..bba5fcbb 100644 --- a/addons/officials/crew/selfmedia-operator/skills/youtube-upload/SKILL.md +++ b/addons/officials/crew/selfmedia-operator/skills/youtube-upload/SKILL.md @@ -35,28 +35,37 @@ Navigate to `https://studio.youtube.com` first. Confirm the channel dashboard lo (Alternative: click the camcorder "Create" icon in YouTube Studio) 2. Wait for the upload dialog to open + - timeout: 15000ms 3. Find the file input element and provide the video file path: - Look for the file picker input (hidden input[type=file] inside ytcp-uploads-file-picker) - Send the absolute file path to the input + - timeout: 10000ms (file path injection itself is fast) -4. Wait for upload progress to complete (watch for the progress indicator) - - Large videos may take 1–3 minutes +4. Wait for upload progress to complete + - Poll the progress indicator every 10 seconds using snapshot + - Do NOT proceed until progress bar disappears or shows "Processing" + - timeout: 300000ms (5 minutes) — large videos can take several minutes + - If still uploading after 5 minutes, inform the user and wait for their reply 5. Fill in the Title: - Click the title field (first text area, id="textbox") - - Clear existing text - - Type the video title from metadata JSON (max 100 characters) + - Wait 1 second after click before typing + - Clear existing text, then type the video title (max 100 characters) + - timeout: 10000ms 6. Fill in the Description: - Click the description field (last text area) - - Clear existing text - - Type the description + hashtags from metadata JSON + - Wait 1 second after click before typing + - Clear existing text, then type the description + hashtags + - timeout: 10000ms 7. Set "Made for kids": - Select "No, it's not made for kids" unless explicitly required 8. Click "Next" three times (Details → Video elements → Checks → Visibility) + - Wait 2 seconds between each "Next" click for the page transition to settle + - timeout per click: 15000ms 9. On the Visibility page, set visibility: - "Public" — for immediate publishing @@ -64,9 +73,11 @@ Navigate to `https://studio.youtube.com` first. Confirm the channel dashboard lo - "Scheduled" — to set a publish date/time 10. Click the "Publish" (or "Save") button + - timeout: 15000ms 11. Wait for confirmation: - - The dialog closes or shows "Your video has been published" + - Poll every 3 seconds for dialog close or "Your video has been published" message + - timeout: 30000ms 12. Retrieve the video URL: - Navigate to https://studio.youtube.com/channel//videos/short diff --git a/awada/awada-extension/src/message-handler.ts b/awada/awada-extension/src/message-handler.ts index 8774962c..9eef5c79 100644 --- a/awada/awada-extension/src/message-handler.ts +++ b/awada/awada-extension/src/message-handler.ts @@ -12,6 +12,63 @@ import { cacheOutboundTarget } from "./target-cache.js"; import { getAwadaRuntime } from "./runtime.js"; import { buildOutboundTarget, encodeAwadaTo, sendTextToAwada } from "./send.js"; +type AwadaDebounceEntry = { + cfg: ClawdbotConfig; + event: InboundEvent; + runtime: RuntimeEnv | undefined; + accountId: string; +}; + +// One debouncer per accountId, created lazily on first message. +type AnyDebouncer = { enqueue: (item: AwadaDebounceEntry) => Promise }; +const _debouncersByAccount = new Map(); + +function getOrCreateDebouncer(accountId: string, cfg: ClawdbotConfig): AnyDebouncer { + const existing = _debouncersByAccount.get(accountId); + if (existing) return existing; + + const core = getAwadaRuntime(); + const debounceMs = core.channel.debounce.resolveInboundDebounceMs({ cfg, channel: "awada" }); + + const debouncer = core.channel.debounce.createInboundDebouncer({ + debounceMs, + buildKey: (entry) => `awada:${entry.accountId}:${entry.event.meta.user_id_external}`, + shouldDebounce: (entry) => { + const { payload } = entry.event; + const hasNonText = payload.some( + (item) => item.type === "image" || item.type === "file" || item.type === "audio", + ); + if (hasNonText) return false; + return Boolean(extractTextFromPayload(payload)); + }, + onFlush: async (entries) => { + const last = entries.at(-1); + if (!last) return; + if (entries.length === 1) { + await _dispatchAwadaEvent(last); + return; + } + const combinedText = entries + .map((e) => extractTextFromPayload(e.event.payload)) + .filter(Boolean) + .join("\n"); + const mergedEvent: InboundEvent = { + ...last.event, + payload: [{ type: "text", text: combinedText }], + }; + await _dispatchAwadaEvent({ ...last, event: mergedEvent }); + }, + onError: (err, entries) => { + const id = entries[0]?.accountId ?? "default"; + const logErr = entries[0]?.runtime?.error ?? console.error; + logErr(`awada[${id}]: inbound debounce flush failed: ${String(err)}`); + }, + }); + + _debouncersByAccount.set(accountId, debouncer); + return debouncer; +} + /** * Extract text from a payload array. Returns the concatenated text of all text objects. */ @@ -158,24 +215,14 @@ async function processFiles( } /** - * Handle a single inbound awada event, dispatching to the OpenClaw agent. + * Core dispatch logic for a single (possibly merged) awada event. */ -export async function handleAwadaMessage(params: { - cfg: ClawdbotConfig; - event: InboundEvent; - runtime?: RuntimeEnv; - accountId?: string; -}): Promise { - const { cfg, event, runtime, accountId = DEFAULT_ACCOUNT_ID } = params; +async function _dispatchAwadaEvent(entry: AwadaDebounceEntry): Promise { + const { cfg, event, runtime, accountId } = entry; const log = runtime?.log ?? console.log; const error = runtime?.error ?? console.error; const account = resolveAwadaAccount({ cfg, accountId }); - if (!account.enabled || !account.configured) { - log(`awada[${accountId}]: account not enabled or configured, skipping`); - return; - } - const { meta, payload, event_id, correlation_id, trace_id } = event; // ---- Classify payload items ---- @@ -184,7 +231,6 @@ export async function handleAwadaMessage(params: { const files = payload.filter((item): item is FileObject => item.type === "file"); const audios = payload.filter((item): item is AudioObject => item.type === "audio"); - // ---- Build reply target early (needed for audio failure reply) ---- const target = buildOutboundTarget({ lane: meta.lane, tenant_id: meta.tenant_id, @@ -194,9 +240,6 @@ export async function handleAwadaMessage(params: { conversation_id: meta.conversation_id, }); - // Cache outbound target so handleAction can reach this peer later - cacheOutboundTarget(meta.user_id_external, target); - // ---- Handle audio: transcribe via SiliconFlow, then treat as text ---- let audioTranscript = ""; for (const audio of audios) { @@ -210,7 +253,6 @@ export async function handleAwadaMessage(params: { audioTranscript += (audioTranscript ? "\n" : "") + result.text; } else { error(`awada[${accountId}]: audio transcription failed: ${result.error}`); - // Send polite decline and return — do not dispatch to agent await sendTextToAwada({ redisUrl: account.redisUrl!, target, @@ -238,7 +280,6 @@ export async function handleAwadaMessage(params: { // ---- Combine text sources ---- const effectiveText = [textContent, audioTranscript].filter(Boolean).join("\n").trim(); - // Skip if no processable content at all if (!effectiveText && images.length === 0 && files.length === 0) { log(`awada[${accountId}]: no processable content for event ${event_id}, skipping`); return; @@ -259,7 +300,6 @@ export async function handleAwadaMessage(params: { mediaTypes.push(...fileResult.types); } - // Use text or a media placeholder if text is empty but media is present const displayText = effectiveText || (mediaPaths.length > 0 ? "" : ""); log( @@ -268,11 +308,9 @@ export async function handleAwadaMessage(params: { ); const core = getAwadaRuntime(); - const awadaTo = encodeAwadaTo(target); const awadaFrom = `awada:${meta.user_id_external}`; - // Resolve agent route const route = core.channel.routing.resolveAgentRoute({ cfg, channel: "awada", @@ -280,7 +318,6 @@ export async function handleAwadaMessage(params: { peer: { kind: "direct", id: sanitizePeerId(meta.user_id_external) }, }); - // Build agent envelope const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); const messageBody = displayText; const body = core.channel.reply.formatAgentEnvelope({ @@ -309,11 +346,9 @@ export async function handleAwadaMessage(params: { Timestamp: event.timestamp * 1000, OriginatingChannel: "awada" as const, OriginatingTo: awadaTo, - // Expose customer identity to the agent via UntrustedContext. UntrustedContext: [ `awada_customer_id: ${meta.platform}:${meta.channel_id}:${meta.user_id_external}:${meta.lane}`, ], - // Media attachments — openclaw generates [media attached: ...] notes automatically ...(mediaPaths.length > 0 ? { MediaPaths: mediaPaths, MediaTypes: mediaTypes } : {}), @@ -347,3 +382,39 @@ export async function handleAwadaMessage(params: { error(`awada[${accountId}]: dispatch failed: ${String(err)}`); } } + +/** + * Handle a single inbound awada event, dispatching to the OpenClaw agent. + * Consecutive text-only messages from the same peer are debounced and merged + * before dispatch so the agent sees one combined message instead of many turns. + */ +export async function handleAwadaMessage(params: { + cfg: ClawdbotConfig; + event: InboundEvent; + runtime?: RuntimeEnv; + accountId?: string; +}): Promise { + const { cfg, event, runtime, accountId = DEFAULT_ACCOUNT_ID } = params; + const log = runtime?.log ?? console.log; + + const account = resolveAwadaAccount({ cfg, accountId }); + if (!account.enabled || !account.configured) { + log(`awada[${accountId}]: account not enabled or configured, skipping`); + return; + } + + // Cache outbound target immediately so handleAction can reach this peer + // even before the debounce window expires. + const target = buildOutboundTarget({ + lane: event.meta.lane, + tenant_id: event.meta.tenant_id, + channel_id: event.meta.channel_id, + user_id_external: event.meta.user_id_external, + platform: event.meta.platform, + conversation_id: event.meta.conversation_id, + }); + cacheOutboundTarget(event.meta.user_id_external, target); + + const debouncer = getOrCreateDebouncer(accountId, cfg); + await debouncer.enqueue({ cfg, event, runtime, accountId }); +} diff --git a/config-templates/openclaw.json b/config-templates/openclaw.json index a27e58ff..a5a60852 100644 --- a/config-templates/openclaw.json +++ b/config-templates/openclaw.json @@ -207,7 +207,10 @@ } ], "messages": { - "ackReactionScope": "group-mentions" + "ackReactionScope": "group-mentions", + "inbound": { + "debounceMs": 1500 + } }, "session": { "dmScope": "per-channel-peer", @@ -374,10 +377,16 @@ } }, "plugins": { + "load": { + "paths": [] + }, "entries": { "feishu": { "enabled": true }, + "awada": { + "enabled": false + }, "xai": { "enabled": false }, diff --git a/crews/shared/COMMAND_TIERS.md b/crews/shared/COMMAND_TIERS.md index e6878ac1..73461bb3 100644 --- a/crews/shared/COMMAND_TIERS.md +++ b/crews/shared/COMMAND_TIERS.md @@ -102,17 +102,3 @@ command-tier: T2 |------|------| | 2026-03-13 | v2: 权限从纯提示词改为 exec-approvals + tools.exec 自动强制执行 | | 2026-03-10 | v1: 初始版本,定义 T0-T3 四层权限 | - ---- - -## Exec 调用规范(所有 Crew 通用) - -**以下写法会导致 exec allowlist miss,禁止使用:** - -- ❌ `cd /abs/path && bash ./skills/xxx/scripts/yyy.sh` — CWD 已经是 workspace,无需 `cd` 前缀 -- ❌ `KEY=value python3 script.py` — 禁止内联 env 赋值;所有环境变量由系统注入,内联赋值导致 allowlist miss - -**正确写法:** - -- ✅ `bash ./skills/xxx/scripts/yyy.sh` -- ✅ `python3 {baseDir}/scripts/gen.py` diff --git a/patches/001-suppress-stale-reply-context.patch b/patches/001-suppress-stale-reply-context.patch deleted file mode 100644 index 6004cdf0..00000000 --- a/patches/001-suppress-stale-reply-context.patch +++ /dev/null @@ -1,160 +0,0 @@ -diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts -index 3e6b333859..4e2880e04d 100644 ---- a/src/auto-reply/dispatch.ts -+++ b/src/auto-reply/dispatch.ts -@@ -1,4 +1,8 @@ - import type { OpenClawConfig } from "../config/types.openclaw.js"; -+import { -+ nextInboundSeq, -+ runWithInboundTurn, -+} from "../infra/outbound/inbound-turn-context.js"; - import { withReplyDispatcher } from "./dispatch-dispatcher.js"; - import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js"; - import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.types.js"; -@@ -41,17 +45,19 @@ export async function dispatchInboundMessage(params: { - replyResolver?: GetReplyFromConfig; - }): Promise { - const finalized = finalizeInboundContext(params.ctx); -- return await withReplyDispatcher({ -- dispatcher: params.dispatcher, -- run: () => -- dispatchReplyFromConfig({ -- ctx: finalized, -- cfg: params.cfg, -- dispatcher: params.dispatcher, -- replyOptions: params.replyOptions, -- replyResolver: params.replyResolver, -- }), -- }); -+ return await runWithInboundTurn(nextInboundSeq(), finalized.From, () => -+ withReplyDispatcher({ -+ dispatcher: params.dispatcher, -+ run: () => -+ dispatchReplyFromConfig({ -+ ctx: finalized, -+ cfg: params.cfg, -+ dispatcher: params.dispatcher, -+ replyOptions: params.replyOptions, -+ replyResolver: params.replyResolver, -+ }), -+ }), -+ ); - } - - export async function dispatchInboundMessageWithBufferedDispatcher(params: { -diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts -index 6dbd3f9e1a..201cc6ecdd 100644 ---- a/src/auto-reply/reply/dispatch-from-config.ts -+++ b/src/auto-reply/reply/dispatch-from-config.ts -@@ -25,6 +25,11 @@ import { - } from "../../hooks/message-hook-mappers.js"; - import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; - import { formatErrorMessage } from "../../infra/errors.js"; -+import { -+ getCurrentInboundSeq, -+ nextInboundSeq, -+ runWithInboundTurn, -+} from "../../infra/outbound/inbound-turn-context.js"; - import { - logMessageProcessed, - logMessageQueued, -@@ -212,6 +217,14 @@ export type { - export async function dispatchReplyFromConfig( - params: DispatchFromConfigParams, - ): Promise { -+ // Channels that bypass dispatchInboundMessage (e.g. awada) call us directly -+ // without an inbound turn context. Establish one here so that the -+ // suppress-stale-reply plugin can track seqs correctly. -+ if (getCurrentInboundSeq() === undefined) { -+ return runWithInboundTurn(nextInboundSeq(), params.ctx.From, () => -+ dispatchReplyFromConfig(params), -+ ); -+ } - const { ctx, cfg, dispatcher } = params; - const diagnosticsEnabled = isDiagnosticsEnabled(cfg); - const channel = normalizeLowercaseStringOrEmpty(ctx.Surface ?? ctx.Provider ?? "unknown"); -@@ -567,11 +580,16 @@ export async function dispatchReplyFromConfig( - - // Trigger plugin hooks (fire-and-forget) - if (hookRunner?.hasHooks("message_received")) { -+ const baseReceivedEvent = toPluginMessageReceivedEvent(hookContext); -+ const receivedEvent = { -+ ...baseReceivedEvent, -+ metadata: { -+ ...baseReceivedEvent.metadata, -+ originatingInboundSeq: getCurrentInboundSeq(), -+ }, -+ }; - fireAndForgetHook( -- hookRunner.runMessageReceived( -- toPluginMessageReceivedEvent(hookContext), -- toPluginMessageContext(hookContext), -- ), -+ hookRunner.runMessageReceived(receivedEvent, toPluginMessageContext(hookContext)), - "dispatch-from-config: message_received plugin hook failed", - ); - } -diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts -index 26688b3093..c9ae9ec26a 100644 ---- a/src/infra/outbound/deliver.ts -+++ b/src/infra/outbound/deliver.ts -@@ -43,6 +43,7 @@ import { - withActiveDeliveryClaim, - } from "./delivery-queue.js"; - import type { OutboundIdentity } from "./identity.js"; -+import { getCurrentInboundFrom, getCurrentInboundSeq } from "./inbound-turn-context.js"; - import type { DeliveryMirror } from "./mirror.js"; - import { - createOutboundPayloadPlan, -@@ -597,6 +598,8 @@ async function applyMessageSendingHook(params: { - channel: params.channel, - accountId: params.accountId, - mediaUrls: params.payloadSummary.mediaUrls, -+ originatingInboundSeq: getCurrentInboundSeq(), -+ originatingFrom: getCurrentInboundFrom(), - }, - }, - { -diff --git a/src/infra/outbound/inbound-turn-context.ts b/src/infra/outbound/inbound-turn-context.ts -new file mode 100644 -index 0000000000..436c5745d1 ---- /dev/null -+++ b/src/infra/outbound/inbound-turn-context.ts -@@ -0,0 +1,37 @@ -+/** -+ * Inbound turn context (wiseflow patch 001). -+ * -+ * Tracks a monotonic per-process "inbound seq" via AsyncLocalStorage so that -+ * downstream message hooks (`message_received`, `message_sending`) can carry -+ * the originating inbound's sequence number in their metadata payload. -+ * -+ * This is the seam used by the `suppress-stale-reply` plugin to drop replies -+ * for inbounds that have been superseded by newer user messages. -+ */ -+ -+import { AsyncLocalStorage } from "node:async_hooks"; -+ -+const storage = new AsyncLocalStorage<{ seq: number; from: string | undefined }>(); -+ -+let counter = 0; -+ -+export function nextInboundSeq(): number { -+ counter += 1; -+ return counter; -+} -+ -+export function runWithInboundTurn( -+ seq: number, -+ from: string | undefined, -+ fn: () => Promise, -+): Promise { -+ return storage.run({ seq, from }, fn); -+} -+ -+export function getCurrentInboundSeq(): number | undefined { -+ return storage.getStore()?.seq; -+} -+ -+export function getCurrentInboundFrom(): string | undefined { -+ return storage.getStore()?.from; -+} diff --git a/patches/README.md b/patches/README.md index d2f6bc58..bfca4f16 100644 --- a/patches/README.md +++ b/patches/README.md @@ -1,6 +1,6 @@ # Wiseflow Patches -wiseflow 针对原版 openclaw 提供的非侵入式补丁与插件,作为 wiseflow 的共性基础能力,由 `apply-addons.sh` 自动应用。 +wiseflow 针对原版 openclaw 提供的非侵入式补丁与依赖覆盖,作为 wiseflow 的共性基础能力,由 `apply-addons.sh` 自动应用。 ### 1. 代码补丁(*.patch) @@ -8,20 +8,13 @@ wiseflow 针对原版 openclaw 提供的非侵入式补丁与插件,作为 wis | 补丁 | 功能 | |------|------| -| `001-suppress-stale-reply-context.patch` | 为 suppress-stale-reply 插件提供上下文支持,确保被抑制的回复仍写入对话历史 | | `002-disable-web-search-env-var.patch` | 添加 `OPENCLAW_DISABLE_WEB_SEARCH` 环境变量,可按需禁用内置 web_search | | `003-act-field-validation.patch` | 强化浏览器工具的 act 字段校验,防止幻觉动作 | -| `004-no-fallback-sticky.patch` | 添加 `OPENCLAW_NO_FALLBACK_STICKY=1` 环境变量,每轮始终从默认模型重试,避免 fallback 跨轮累积成本 | +| `005-browser-timeout-env-var.patch` | 添加 `OPENCLAW_BROWSER_TIMEOUT` 环境变量,支持自定义浏览器超时 | -> **注**:原 `004-web-fetch-allow-rfc2544.patch`(允许 web fetch 访问 RFC 2544 保留地址段,国内 Clash fake-IP 场景)已于 openclaw v2026.4.10 被上游原生集成为 `tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange` 配置项,patch 已移除,默认在 `config-templates/openclaw.json` 中开启。 +> **注**:原 `001-suppress-stale-reply-context.patch` 已于 2026-04-25 移除,awada channel 改用 per-peer inbound debouncer 在入口层合并连发消息,无需 openclaw 核心补丁即可解决历史污染问题。 -### 2. 插件(suppress-stale-reply/) - -| 插件目录 | 功能 | -|---------|------| -| `suppress-stale-reply/` | 用户连续快速发送多条消息时,agent 对被超越消息的回复不发送给用户(仍写入历史);`/`-前缀指令型回复放行;可通过 `OPENCLAW_SUPPRESS_STALE_REPLY=0` 关闭 | - -### 3. 依赖覆盖(overrides.sh) +### 2. 依赖覆盖(overrides.sh) `overrides.sh` 在 openclaw 恢复干净状态后最先执行,用于注入 pnpm 依赖覆盖(如 patchright 替换 playwright)。 diff --git a/patches/overrides.sh b/patches/overrides.sh index 79fdd676..ed16907c 100755 --- a/patches/overrides.sh +++ b/patches/overrides.sh @@ -4,7 +4,7 @@ # 由 apply-addons.sh 调用,接收环境变量:ADDON_DIR, OPENCLAW_DIR set -e -PATCHRIGHT_VERSION="${PATCHRIGHT_VERSION:-1.58.2}" +PATCHRIGHT_VERSION="${PATCHRIGHT_VERSION:-1.59.4}" # ─── pnpm overrides(核心,不修改源码) ───────────────────────── echo " → pnpm override: playwright-core → patchright-core@${PATCHRIGHT_VERSION}" diff --git a/patches/suppress-stale-reply/index.ts b/patches/suppress-stale-reply/index.ts deleted file mode 100644 index 501d5cd2..00000000 --- a/patches/suppress-stale-reply/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * suppress-stale-reply — wiseflow official plugin - * - * 抑制被后续用户消息取代的 agent 回复:当用户连续发送 A/B/C 三条消息且间隔 - * 很短时,只把对最新消息(C)的回复发给用户;reply2A / reply2B 仍会写入 - * agent 历史(下一轮生成可见),但不会被真正发送。 - * - * 依赖 wiseflow patch 001,后者把 originating inbound seq 透传到 - * `message_received` 与 `message_sending` hook 的 `metadata.originatingInboundSeq`。 - * - * 不变量: - * - 内容以 "/" 开头的 reply(如 /kb、/cc)永不拦截——这类是 agent 生成的 - * "指令型回复",需要完整流过后续 message_sending hook 链,不能被 short-circuit。 - * - 通过环境变量 `OPENCLAW_SUPPRESS_STALE_REPLY=0` 关闭整个插件。 - */ - -import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; - -type InboundMetadata = { - originatingInboundSeq?: number; - originatingFrom?: string; -} & Record; - -function peerKey( - channelId: string | undefined, - accountId: string | undefined, - peer: string, -): string { - return `${channelId ?? ""}|${accountId ?? ""}|${peer}`; -} - -function coerceSeq(metadata: unknown): number | undefined { - if (!metadata || typeof metadata !== "object") { - return undefined; - } - const seq = (metadata as InboundMetadata).originatingInboundSeq; - return typeof seq === "number" && Number.isFinite(seq) ? seq : undefined; -} - -export default definePluginEntry({ - id: "suppress-stale-reply", - name: "Suppress Stale Reply", - description: - "Drops agent replies superseded by newer inbound user messages (history preserved)", - register(api) { - if (process.env.OPENCLAW_SUPPRESS_STALE_REPLY === "0") { - api.logger.info("suppress-stale-reply: disabled via OPENCLAW_SUPPRESS_STALE_REPLY=0"); - return; - } - - const latestInboundSeqByPeer = new Map(); - - api.on("message_received", (event, ctx) => { - const seq = coerceSeq(event.metadata); - if (seq === undefined) { - return; - } - const key = peerKey(ctx.channelId, ctx.accountId, event.from); - const current = latestInboundSeqByPeer.get(key) ?? 0; - if (seq > current) { - latestInboundSeqByPeer.set(key, seq); - } - }); - - api.on("message_sending", (event, ctx) => { - if (event.content.trimStart().startsWith("/")) { - return; - } - const turnSeq = coerceSeq(event.metadata); - if (turnSeq === undefined) { - return; - } - // Use originatingFrom (the inbound sender address) as the peer key so it - // matches what was stored during message_received. The event.to field uses - // the encoded delivery address which differs in format from event.from. - const peer = (event.metadata as InboundMetadata).originatingFrom ?? event.to; - const key = peerKey(ctx.channelId, ctx.accountId, peer); - const latestSeq = latestInboundSeqByPeer.get(key); - if (latestSeq !== undefined && turnSeq < latestSeq) { - api.logger.debug( - `suppress-stale-reply: cancel reply for turn=${turnSeq} (latest=${latestSeq}) peer=${key}`, - ); - return { cancel: true }; - } - return; - }); - }, -}); diff --git a/patches/suppress-stale-reply/openclaw.plugin.json b/patches/suppress-stale-reply/openclaw.plugin.json deleted file mode 100644 index d7aefecf..00000000 --- a/patches/suppress-stale-reply/openclaw.plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "suppress-stale-reply", - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean" } - } - } -} diff --git a/patches/suppress-stale-reply/package.json b/patches/suppress-stale-reply/package.json deleted file mode 100644 index 9157a227..00000000 --- a/patches/suppress-stale-reply/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@wiseflow/suppress-stale-reply", - "version": "0.1.0", - "private": true, - "description": "Suppress agent replies superseded by newer user messages in the same conversation", - "type": "module", - "dependencies": {}, - "peerDependencies": { - "openclaw": "*" - }, - "peerDependenciesMeta": { - "openclaw": { - "optional": true - } - }, - "openclaw": { - "extensions": [ - "./index.ts" - ], - "install": { - "localPath": "addons/officials/plugins/suppress-stale-reply", - "defaultChoice": "local" - } - } -} diff --git a/scripts/apply-addons.sh b/scripts/apply-addons.sh index 5f6ce030..5374fdbd 100755 --- a/scripts/apply-addons.sh +++ b/scripts/apply-addons.sh @@ -8,7 +8,7 @@ # # 每次运行时: # 1. 恢复 openclaw/ 到干净状态 -# 2. 应用基础补丁(patches/*.patch)+ 插件路径注入(patches/suppress-stale-reply)+ 依赖覆盖(patches/overrides.sh) +# 2. 应用基础补丁(patches/*.patch)+ 依赖覆盖(patches/overrides.sh) # 3. 安装默认全局 skills(项目根目录 skills/) # 4. 扫描 addons/*/ 目录,对每个 addon 依次执行: # a. skills/*/SKILL.md — 额外全局 skill 安装 @@ -236,30 +236,6 @@ if [ -d "$AWADA_EXT" ] && [ -f "$AWADA_EXT/openclaw.plugin.json" ]; then fi fi -# ─── 注入 suppress-stale-reply 插件路径(默认开启) ────────────── -SUPPRESS_STALE_PLUGIN="$PROJECT_ROOT/patches/suppress-stale-reply" -if [ -d "$SUPPRESS_STALE_PLUGIN" ] && [ -f "$SUPPRESS_STALE_PLUGIN/openclaw.plugin.json" ]; then - if [ -f "$CONFIG_PATH" ]; then - node -e " - const fs = require('fs'); - const config = JSON.parse(fs.readFileSync('$CONFIG_PATH', 'utf8')); - if (!config.plugins) config.plugins = {}; - if (!config.plugins.load) config.plugins.load = {}; - if (!Array.isArray(config.plugins.load.paths)) config.plugins.load.paths = []; - const pluginPath = '$SUPPRESS_STALE_PLUGIN'; - config.plugins.load.paths = config.plugins.load.paths.filter( - p => !p.endsWith('patches/suppress-stale-reply') && !p.endsWith('plugins/suppress-stale-reply') - ); - config.plugins.load.paths.push(pluginPath); - if (!config.plugins.entries) config.plugins.entries = {}; - if (!config.plugins.entries['suppress-stale-reply']) { - config.plugins.entries['suppress-stale-reply'] = { enabled: true }; - } - fs.writeFileSync('$CONFIG_PATH', JSON.stringify(config, null, 2) + '\n'); - " - echo "📝 suppress-stale-reply plugin path injected" - fi -fi # ─── 安装全局共享技能(项目根目录 skills/) ──────���────────────── GLOBAL_SKILL_COUNT=0 diff --git a/scripts/install.sh b/scripts/install.sh index 873d4b3b..d80ab267 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -18,7 +18,7 @@ # ⚠️ 升级前请确保系统空闲(无 agent 会话正在处理任务) set -e -OFB_REPO="https://github.com/TeamWiseFlow/wiseflow.git" +OFB_REPO="https://gitcode.com/wiseflow/wiseflow.git" PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" OPENCLAW_DIR="$PROJECT_ROOT/openclaw" VERSION_FILE="$PROJECT_ROOT/openclaw.version" diff --git a/scripts/lib/agent-skills.sh b/scripts/lib/agent-skills.sh index ab29ed3a..41de8da2 100644 --- a/scripts/lib/agent-skills.sh +++ b/scripts/lib/agent-skills.sh @@ -470,11 +470,19 @@ some-cmd > /tmp/out.txt # 判断文件是否存在 [ -f /tmp/file.txt ] && echo "EXISTS" || echo "NOT" test -f /tmp/file.txt && echo "EXISTS" || echo "NOT" - -# 写文件用 write 工具;读文件用 read 工具 ``` 如果确实需要重定向,请改用 `bash -c "..."` 方式,并确保 `bash` 已在 exec allowlist 中(T2 及以上 tier 默认包含)。 + +**以下写法同样会导致 allowlist miss,禁止使用:** + +- ❌ `cd /abs/path && bash ./skills/xxx/scripts/yyy.sh` — CWD 已经是 workspace,不需要 `cd` 前缀,`cd` 不在 allowlist 中 +- ❌ `KEY=value python3 script.py` — 内联 env 赋值会改变命令前缀导致 allowlist miss;环境变量由系统注入 + +**正确写法:** + +- ✅ `bash ./skills/xxx/scripts/yyy.sh`(直接相对路径调用) +- ✅ `python3 /abs/path/to/script.py`(无 env 前缀) GUIDE } diff --git a/scripts/setup-crew.sh b/scripts/setup-crew.sh index e306ae7d..abcf21c9 100755 --- a/scripts/setup-crew.sh +++ b/scripts/setup-crew.sh @@ -690,11 +690,11 @@ cd $PROJECT_ROOT && ./scripts/setup-crew.sh # 重新应用 addons cd $PROJECT_ROOT && ./scripts/apply-addons.sh -# 生产模式重装后台服务 -cd $PROJECT_ROOT && ./scripts/reinstall-daemon.sh - # 升级 wiseflow 系统(须确认系统空闲) -cd $PROJECT_ROOT && ./scripts/upgrade.sh +cd $PROJECT_ROOT && ./scripts/install.sh + +# 仅重装后台服务(不更新代码) +cd $PROJECT_ROOT && ./scripts/install.sh --skip-crew # 直接调用上游 CLI(如需) cd $PROJECT_ROOT/openclaw && pnpm openclaw