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 任务完成后回复通知

在 RunService 的 finishRun 方法中添加回调:

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工程的实战经验!

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


相关推荐
aibo860308几秒前
基于 Spring Boot + Vue3 的菜谱分享网站设计与实现
java·spring boot·后端
凤山老林2 分钟前
Spring Boot 集成 TOTP 双因素认证与恢复码机制
java·spring boot·后端
小冯不疯4 分钟前
物料主数据同步实战:从金蝶云星空到小满OKKICRM的单一策略深度拆解
java·大数据·数据库
vipxieliang8 分钟前
Spring Boot 测试策略:单元测试、切片测试与集成测试
java·spring boot·后端
估值探索者20 分钟前
【Python量化技术指标实战 04】布林带通道突破,怎么知道是真突破还是假突破?
java·开发语言·python·接口·股票数据api接口
未秃头的程序猿20 分钟前
同一个商户号,V2 免密代扣和 V3 分账的证书怎么共存?
java·后端·微信
captain37635 分钟前
Spring Boot的配置文件
java·spring boot·后端
captain37636 分钟前
Spring Boot日志
java·数据库·spring boot
Rabitebla1 小时前
【Linux】 权限篇(三):深入理解 umask 位运算与粘滞位的设计思想
java·linux·服务器·c++·笔记·学习
Pin_Qing1 小时前
缓存能活多久,现在是个要算的参数
java·大数据·开发语言·人工智能·程序人生