OpenClaw 只能命令行触发?自研企业微信实现发消息即执行

系列: SmartClaw × OpenClaw:企业级浏览器自动化实战(第⑤篇)
日期: 2026-05-04
标签: OpenClaw, 企业微信, Webhook, Spring Boot, AES 解密, 消息触发
适合谁看: Spring Boot 开发、企业微信集成、后端工程师


前言

OpenClaw 需要通过命令行或 API 触发任务,非技术人员无法使用。

场景: 门店员工需要通过微信发起设备报修,但她们不会用命令行,只会用微信。

SmartClaw 的做法: 接入企业微信 Webhook,员工发送"报修 空调不制冷 3楼302 张三",系统自动提取信息并创建工单,完成后回复"✅ 工单已创建"。

本文是系列第⑤篇,完整讲解 Spring Boot 如何接入企业微信 Webhook,实现"发消息即执行"的自动化流程。


一、OpenClaw 的交互局限

1.1 需要 CLI/API 触发

OpenClaw 的典型使用方式:

bash 复制代码
# 命令行触发
openclaw run --prompt "登录系统,填写表单"

# 或者调用 API
curl -X POST http://localhost:3000/api/run \
  -d '{"prompt": "登录系统,填写表单"}'

问题:

  • 非技术人员无法使用
  • 无法与现有工作流集成
  • 移动端操作不便

1.2 对比表格

维度 OpenClaw SmartClaw + 企业微信
触发方式 CLI/API 微信消息
用户门槛 高(需懂技术) 低(会发微信即可)
移动支持
工作流集成

二、业务场景:门店报修自动派单

2.1 背景

某连锁零售集团有 50 个门店,每个门店每天需要:

  • 将设备报修信息自动创建到工单系统
  • 人工操作耗时 3 分钟/条
  • 每天约 100 条,总耗时 5 小时

2.2 消息格式约定

前台通过企业微信发送:

复制代码
报修 空调不制冷 3楼302 张三

2.3 执行流程

🧰 工单系统 ⚙️ SmartClaw Agent 🖥️ SmartClaw Server 🏢 企业微信服务器 👤 前台员工 🧰 工单系统 ⚙️ SmartClaw Agent 🖥️ SmartClaw Server 🏢 企业微信服务器 👤 前台员工 发送消息 "报修 空调不制冷 3楼302 张三" Webhook 回调 POST /api/wechat/work/callback AES 解密 + 规则匹配 提取变量 issue=空调不制冷, location=3楼302, reporter=张三 下发任务 templateId=store-repair-ticket Playwright 自动填单 返回结果 回传执行结果 调用 API 回复 收到回复 "✅ 工单已创建,流水号:c6efed0a"


三、核心技术实现

3.1 企业微信配置

步骤 1:创建应用
  1. 登录 企业微信管理后台
  2. 进入「应用管理」→「创建应用」
  3. 获取以下参数:
    • corp_id:企业 ID
    • agent_id:应用 ID
    • agent_secret:应用密钥
步骤 2:配置回调 URL
  1. 进入「应用管理」→ 选择应用 →「接收消息」
  2. 设置回调 URL:https://your-domain.com/api/wechat/work/callback
  3. 设置 Token(自定义,如 SmartClaw2026
  4. 设置 EncodingAESKey(随机生成 43 位字符)
步骤 3:配置 IP 白名单

将 SmartClaw Server 的公网 IP 加入白名单。

3.2 Spring Boot 配置

yaml 复制代码
# application.yml
smartclaw:
  wechat:
    work:
      enabled: true
      corp-id: wwxxxxxxxxxxxxx
      agent-id: 1000001
      agent-secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
      token: SmartClaw2026
      encoding-aes-key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

3.3 回调接收控制器

java 复制代码
@RestController
@RequestMapping("/api/wechat/work")
@Slf4j
public class WechatWorkCallbackController {
    
    @Autowired
    private WxMsgDecryptService decryptService;
    
    @Autowired
    private WechatMessageDispatcher dispatcher;
    
    /**
     * 企业微信验证 URL 有效性(GET 请求)
     */
    @GetMapping("/callback")
    public String verify(
            @RequestParam String msg_signature,
            @RequestParam String timestamp,
            @RequestParam String nonce,
            @RequestParam String echostr) {
        
        log.info("Verifying URL: timestamp={}, nonce={}", timestamp, nonce);
        
        try {
            // 验签 + 解密 echostr
            String decryptedEchostr = decryptService.verifyUrl(
                msg_signature, timestamp, nonce, echostr
            );
            
            log.info("URL verification successful");
            return decryptedEchostr;  // 必须原样返回
            
        } catch (Exception e) {
            log.error("URL verification failed", e);
            return "fail";
        }
    }
    
    /**
     * 接收企业微信推送消息(POST 请求)
     */
    @PostMapping("/callback")
    public String callback(
            @RequestBody String rawXml,
            @RequestParam String msg_signature,
            @RequestParam String timestamp,
            @RequestParam String nonce) {
        
        log.info("Received message: timestamp={}", timestamp);
        
        try {
            // 1. 验签 + 解密
            WxWorkMessage message = decryptService.decryptMessage(
                rawXml, msg_signature, timestamp, nonce
            );
            
            log.info("Decrypted message: from={}, content={}", 
                     message.getFromUserName(), message.getContent());
            
            // 2. 只处理文本消息
            if ("text".equalsIgnoreCase(message.getMsgType())) {
                // 3. 异步 dispatch,避免阻塞企业微信回调
                dispatcher.dispatchAsync(message);
            }
            
            // 4. 必须返回 success,否则企业微信会重试
            return "success";
            
        } catch (Exception e) {
            log.error("Callback processing failed", e);
            // 即使失败也要返回 success,避免企业微信无限重试
            return "success";
        }
    }
}

3.4 AES 解密服务

企业微信使用 AES-256-CBC 加密消息体,需要官方提供的加解密库。

引入依赖
xml 复制代码
<!-- pom.xml -->
<dependency>
    <groupId>com.github.binarywang</groupId>
    <artifactId>weixin-java-cp</artifactId>
    <version>4.5.0</version>
</dependency>
实现解密逻辑
java 复制代码
@Service
@Slf4j
public class WxMsgDecryptService {
    
    @Value("${smartclaw.wechat.work.token}")
    private String token;
    
    @Value("${smartclaw.wechat.work.encoding-aes-key}")
    private String encodingAesKey;
    
    @Value("${smartclaw.wechat.work.corp-id}")
    private String corpId;
    
    /**
     * 验证 URL(GET 请求)
     */
    public String verifyUrl(String msgSignature, String timestamp, 
                            String nonce, String echostr) {
        try {
            WXBizMsgCrypt crypt = new WXBizMsgCrypt(
                token, encodingAesKey, corpId
            );
            
            // 验签 + 解密
            return crypt.VerifyURL(msgSignature, timestamp, nonce, echostr);
            
        } catch (Exception e) {
            log.error("URL verification failed", e);
            throw new RuntimeException("URL verification failed", e);
        }
    }
    
    /**
     * 解密消息(POST 请求)
     */
    public WxWorkMessage decryptMessage(String rawXml, String msgSignature,
                                        String timestamp, String nonce) {
        try {
            WXBizMsgCrypt crypt = new WXBizMsgCrypt(
                token, encodingAesKey, corpId
            );
            
            // 验签 + 解密
            String decryptedXml = crypt.DecryptMsg(
                msgSignature, timestamp, nonce, rawXml
            );
            
            log.debug("Decrypted XML: {}", decryptedXml);
            
            // 解析 XML
            return parseXml(decryptedXml);
            
        } catch (Exception e) {
            log.error("Message decryption failed", e);
            throw new RuntimeException("Message decryption failed", e);
        }
    }
    
    private WxWorkMessage parseXml(String xml) {
        // 使用 JAXB 或 DOM 解析 XML
        // 简化示例:
        Document doc = DocumentBuilderFactory.newInstance()
            .newDocumentBuilder()
            .parse(new InputSource(new StringReader(xml)));
        
        WxWorkMessage message = new WxWorkMessage();
        message.setFromUserName(getNodeText(doc, "FromUserName"));
        message.setToUserName(getNodeText(doc, "ToUserName"));
        message.setMsgType(getNodeText(doc, "MsgType"));
        message.setContent(getNodeText(doc, "Content"));
        message.setMsgId(getNodeText(doc, "MsgId"));
        message.setCreateTime(Long.parseLong(getNodeText(doc, "CreateTime")));
        
        return message;
    }
    
    private String getNodeText(Document doc, String tagName) {
        NodeList nodes = doc.getElementsByTagName(tagName);
        if (nodes.getLength() > 0) {
            return nodes.item(0).getTextContent();
        }
        return null;
    }
}

3.5 消息规则引擎

规则数据模型
sql 复制代码
CREATE TABLE message_rule (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
    name            VARCHAR(100) NOT NULL COMMENT '规则名称',
    source          VARCHAR(20) NOT NULL COMMENT 'WECHAT_WORK',
    pattern         VARCHAR(500) NOT NULL COMMENT '正则表达式',
    template_id     VARCHAR(100) NOT NULL COMMENT '对应模板ID',
    variable_names  VARCHAR(200) COMMENT '变量名列表,JSON数组',
    priority        INT DEFAULT 0 COMMENT '优先级',
    enabled         TINYINT(1) DEFAULT 1,
    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 示例规则:匹配报修消息
INSERT INTO message_rule (name, source, pattern, template_id, variable_names, priority)
VALUES (
    '门店报修派单',
    'WECHAT_WORK',
    '^报修\\s+(?<issue>[^\\s]{2,20})\\s+(?<location>[^\\s]{2,20})\\s+(?<reporter>[\\u4e00-\\u9fa5]{2,4})$',
    'store-repair-ticket-v1',
    '["issue","location","reporter"]',
    100
);
规则匹配服务
java 复制代码
@Service
@Slf4j
public class MessageRuleService {
    
    @Autowired
    private MessageRuleRepository ruleRepository;
    
    @Autowired
    private TaskDispatchService dispatchService;
    
    /**
     * 匹配消息并触发任务
     */
    public Optional<DispatchResult> matchAndDispatch(WxWorkMessage message) {
        String content = message.getContent().trim();
        String sender = message.getFromUserName();
        
        log.info("Matching message: content={}, sender={}", content, sender);
        
        // 1. 从数据库加载启用的规则(按优先级排序)
        List<MessageRule> rules = ruleRepository.findEnabledRulesOrderByPriorityDesc();
        
        for (MessageRule rule : rules) {
            // 2. 正则匹配
            Matcher matcher = Pattern.compile(rule.getPattern()).matcher(content);
            
            if (matcher.matches()) {
                log.info("Rule matched: ruleName={}", rule.getName());
                
                // 3. 提取命名捕获组作为变量
                Map<String, String> variables = extractNamedGroups(matcher, rule);
                
                // 4. 注入发送者信息
                variables.put("_senderUserId", sender);
                variables.put("_msgId", message.getMsgId());
                
                // 5. 构建 dispatch 请求
                DispatchRequest request = DispatchRequest.builder()
                    .templateId(rule.getTemplateId())
                    .variables(variables)
                    .idempotencyKey("wechat_work:" + message.getMsgId())
                    .source("WECHAT_WORK")
                    .requesterOpenId(sender)
                    .build();
                
                // 6. 下发任务
                DispatchResult result = dispatchService.dispatch(request);
                
                log.info("Task dispatched: runId={}", result.getRunId());
                
                return Optional.of(result);
            }
        }
        
        log.warn("No rule matched for message: {}", content);
        
        // 无匹配规则:发送默认回复
        sendDefaultReply(sender, content);
        
        return Optional.empty();
    }
    
    /**
     * 提取正则命名捕获组
     */
    private Map<String, String> extractNamedGroups(Matcher matcher, MessageRule rule) {
        Map<String, String> vars = new HashMap<>();
        
        // 解析 variable_names JSON 数组
        List<String> varNames = parseJsonArray(rule.getVariableNames());
        
        for (String groupName : varNames) {
            try {
                String value = matcher.group(groupName);
                vars.put(groupName, value);
                log.debug("Extracted variable: {}={}", groupName, value);
            } catch (IllegalArgumentException e) {
                log.warn("Group not found: {}", groupName);
            }
        }
        
        return vars;
    }
    
    private List<String> parseJsonArray(String jsonArray) {
        // 使用 Jackson 或 Gson 解析 JSON
        try {
            return objectMapper.readValue(
                jsonArray, 
                new TypeReference<List<String>>() {}
            );
        } catch (Exception e) {
            log.error("Failed to parse JSON array", e);
            return Collections.emptyList();
        }
    }
}

3.6 消息调度器

java 复制代码
@Service
@Slf4j
public class WechatMessageDispatcher {
    
    @Autowired
    private MessageRuleService ruleService;
    
    @Autowired
    private WechatWorkApiClient wechatApiClient;
    
    /**
     * 异步 dispatch(避免阻塞企业微信回调)
     */
    @Async("wechatExecutor")
    public void dispatchAsync(WxWorkMessage message) {
        try {
            Optional<DispatchResult> result = ruleService.matchAndDispatch(message);
            
            if (result.isPresent()) {
                log.info("Message processed successfully: msgId={}", message.getMsgId());
            } else {
                log.warn("Message not matched: msgId={}", message.getMsgId());
            }
            
        } catch (Exception e) {
            log.error("Message dispatch failed: msgId={}", message.getMsgId(), e);
            
            // 发送错误回复
            wechatApiClient.sendText(
                message.getFromUserName(),
                "❌ 处理失败:" + e.getMessage()
            );
        }
    }
}

3.7 回复消息 API 客户端

java 复制代码
@Service
@Slf4j
public class WechatWorkApiClient {
    
    private static final String TOKEN_URL = 
        "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={}&corpsecret={}";
    private static final String SEND_URL = 
        "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={}";
    
    @Value("${smartclaw.wechat.work.corp-id}")
    private String corpId;
    
    @Value("${smartclaw.wechat.work.agent-secret}")
    private String agentSecret;
    
    @Value("${smartclaw.wechat.work.agent-id}")
    private String agentId;
    
    @Autowired
    private RestTemplate restTemplate;
    
    // AccessToken 本地缓存(有效期 7200s)
    private final LoadingCache<String, String> tokenCache = CacheBuilder.newBuilder()
        .expireAfterWrite(7000, TimeUnit.SECONDS)
        .build(CacheLoader.from(this::fetchAccessToken));
    
    /**
     * 发送文本消息
     */
    public void sendText(String toUserId, String content) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("touser", toUserId);
        body.put("msgtype", "text");
        body.put("agentid", agentId);
        body.put("text", Map.of("content", content));
        
        String token = tokenCache.getUnchecked("token");
        String url = SEND_URL.replace("{}", token);
        
        try {
            ResponseEntity<Map> response = restTemplate.postForEntity(
                url, body, Map.class
            );
            
            Map responseBody = response.getBody();
            Integer errcode = (Integer) responseBody.get("errcode");
            
            if (errcode != 0) {
                log.error("Failed to send message: errcode={}, errmsg={}", 
                         errcode, responseBody.get("errmsg"));
            } else {
                log.info("Message sent successfully: toUserId={}", toUserId);
            }
            
        } catch (Exception e) {
            log.error("Send message failed", e);
        }
    }
    
    /**
     * 发送 Markdown 消息(支持富文本)
     */
    public void sendMarkdown(String toUserId, String markdownContent) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("touser", toUserId);
        body.put("msgtype", "markdown");
        body.put("agentid", agentId);
        body.put("markdown", Map.of("content", markdownContent));
        
        String token = tokenCache.getUnchecked("token");
        String url = SEND_URL.replace("{}", token);
        
        restTemplate.postForEntity(url, body, Map.class);
    }
    
    /**
     * 获取 AccessToken
     */
    private String fetchAccessToken(String key) {
        String url = TOKEN_URL.replace("{}", corpId).replace("{}", agentSecret);
        
        ResponseEntity<Map> response = restTemplate.getForEntity(url, Map.class);
        Map responseBody = response.getBody();
        
        String accessToken = (String) responseBody.get("access_token");
        Integer expiresIn = (Integer) responseBody.get("expires_in");
        
        log.info("Fetched access token, expires in: {}s", expiresIn);
        
        return accessToken;
    }
}

3.8 任务完成后回复通知

RunServicefinishRun 方法中添加回调:

java 复制代码
// RunService.java
@Autowired
private WechatWorkApiClient wechatWorkApiClient;

public void finishRun(String runId, RunFinishRequest request) {
    // ... 更新任务状态
    
    TaskRun run = taskRunRepository.findById(runId);
    
    // 如果是微信触发的任务,发送回复
    if ("WECHAT_WORK".equals(run.getSource())) {
        notifyWechat(run);
    }
}

private void notifyWechat(TaskRun run) {
    String replyContent;
    
    if ("SUCCESS".equals(run.getStatus())) {
        replyContent = String.format(
            "✅ **自动化任务完成**\n" +
            "> 任务:%s\n" +
            "> 流水号:%s\n" +
            "> 耗时:%dms\n" +
            "> 时间:%s",
            run.getTemplateName(),
            run.getRunId().substring(0, 8),
            run.getDurationMs(),
            LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM-dd HH:mm:ss"))
        );
    } else {
        replyContent = String.format(
            "❌ **任务执行失败**\n" +
            "> 任务:%s\n" +
            "> 错误:%s\n" +
            "> 流水号:%s",
            run.getTemplateName(),
            run.getFinalMessage(),
            run.getRunId().substring(0, 8)
        );
    }
    
    wechatWorkApiClient.sendMarkdown(
        run.getRequesterOpenId(), 
        replyContent
    );
}

四、完整执行流程演示

4.1 员工发送消息

复制代码
报修 空调不制冷 3楼302 张三

4.2 企业微信回调

http 复制代码
POST /api/wechat/work/callback?msg_signature=xxx&timestamp=xxx&nonce=xxx
Content-Type: application/xml

<xml>
  <ToUserName><![CDATA[ww...]]></ToUserName>
  <FromUserName><![CDATA[ZhangSan]]></FromUserName>
  <CreateTime>1714204800</CreateTime>
  <MsgType><![CDATA[text]]></MsgType>
  <Content><![CDATA[报修 空调不制冷 3楼302 张三]]></Content>
  <MsgId>1234567890</MsgId>
  <Encrypt><![CDATA[加密后的内容]]></Encrypt>
</xml>

4.3 规则匹配

java 复制代码
// 正则匹配
Pattern: ^报修\s+(?<issue>[^\s]{2,20})\s+(?<location>[^\s]{2,20})\s+(?<reporter>[\u4e00-\u9fa5]{2,4})$

// 提取变量
issue = "空调不制冷"
location = "3楼302"
reporter = "张三"

4.4 下发任务

java 复制代码
DispatchRequest request = DispatchRequest.builder()
    .templateId("store-repair-ticket-v1")
    .variables(Map.of(
        "issue", "空调不制冷",
        "location", "3楼302",
        "reporter", "张三"
    ))
    .idempotencyKey("wechat_work:1234567890")
    .source("WECHAT_WORK")
    .requesterOpenId("ZhangSan")
    .build();

dispatchService.dispatch(request);

4.5 Agent 执行 DSL

yaml 复制代码
steps:
  - stepId: s1
    action: navigate
    params:
      url: "https://support.example.com/ticket/new"
  
  - stepId: s2
    action: fill
    params:
      selector: "input[name='name']"
      value: "${reporter}"  # 张三
  
  - stepId: s3
    action: fill
    params:
      selector: "input[name='idcard']"
      value: "${issue}"  # 空调不制冷
  
  - stepId: s4
    action: fill
    params:
      selector: "input[name='room']"
      value: "${location}"  # 3楼302
  
  - stepId: s5
    action: clickRole
    params:
      role: "button"
      name: "提交工单"
  
  - stepId: s6
    action: waitText
    params:
      text: "工单已创建"
      timeoutMs: 8000

4.6 回复前台

复制代码
✅ **自动化任务完成**
> 任务:门店报修派单
> 流水号:c6efed0a
> 耗时:4823ms
> 时间:04-27 14:32:11

五、性能优化建议

5.1 AccessToken 缓存

企业微信 AccessToken 有效期 7200 秒,不要每次请求都重新获取:

java 复制代码
LoadingCache<String, String> tokenCache = CacheBuilder.newBuilder()
    .expireAfterWrite(7000, TimeUnit.SECONDS)  // 7000 秒过期
    .build(CacheLoader.from(this::fetchAccessToken));

5.2 异步处理消息

企业微信要求 5 秒内响应,因此消息处理必须异步:

java 复制代码
@Async("wechatExecutor")
public void dispatchAsync(WxWorkMessage message) {
    // 异步处理,立即返回 success
}

线程池配置:

java 复制代码
@Configuration
public class WechatExecutorConfig {
    
    @Bean("wechatExecutor")
    public Executor wechatExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("wechat-");
        executor.initialize();
        return executor;
    }
}

5.3 幂等保护

通过消息 ID 保证同一条消息不会重复触发任务:

java 复制代码
.idempotencyKey("wechat_work:" + message.getMsgId())

六、OpenClaw 做不到的事

6.1 自然语言交互

OpenClaw 需要手写 Prompt,而 SmartClaw 可以通过微信消息触发,用户门槛更低。

6.2 工作流集成

SmartClaw 可以与企业微信深度集成,实现:

  • 消息触发任务
  • 任务完成回复
  • 群聊 @机器人触发
  • 审批流程集成

6.3 移动端支持

企业微信支持 iOS/Android,用户可以随时随地触发自动化任务。


七、总结

OpenClaw 展示了 AI 操作浏览器的可能性,但在企业落地场景下,还需要解决交互入口的问题。

SmartClaw 通过接入企业微信 Webhook,实现了"发消息即执行"的自动化流程,将用户门槛降到最低。


相关资源

💬 互动交流

如果你在学习和使用过程中遇到问题,欢迎:
1. 在评论区留言讨论
2.如果觉得有帮助,点赞👍收藏📌关注➕,后续会持续分享SpringAI和AI工程的实战经验!

你的支持是我持续创作的最大动力!


相关推荐
逻辑驱动的ken2 小时前
Java高频面试考点场景题22
java·开发语言·jvm·面试·职场和发展·求职招聘·春招
小则又沐风a2 小时前
list模拟实现
java·服务器·list
上弦月-编程2 小时前
C语言链表详解,新手也能看懂! ——从入门到精通的完整教程
java·c语言·c++
舟遥遥娓飘飘2 小时前
量化投资体系之二:为 Web 看板集成公众号/财经原始数据
前端·数据分析·自动化·ai编程
ffqws_2 小时前
Spring Boot 配置读取全解析:从 application.yml 到 Java 对象的完整链路
java·数据库·spring boot
clear sky .2 小时前
【TCP】TCP数据粘包/分包问题
java·服务器·网络
云烟成雨TD2 小时前
Spring AI 1.x 系列【29】Embedding Model(嵌入模型)
java·人工智能·spring
幸福巡礼2 小时前
【 LangChain 1.2 实战(四)】构建一个模块化的天气查询 Agent
java·前端·langchain
wuminyu12 小时前
专家视角看Java字节码加载与存储指令机制
java·linux·c语言·jvm·c++