对接扣子

This commit is contained in:
tanglong 2026-07-13 17:17:55 +08:00
parent f057ca5053
commit dc08bb52d5
8 changed files with 315 additions and 5 deletions

View File

@ -11,10 +11,26 @@ Java Spring Boot service for Enterprise WeChat callbacks and internal APIs.
- Verifies Enterprise WeChat callback signatures
- Decrypts encrypted callback payloads when safe mode is enabled
- Deduplicates text messages
- Sends a fixed text reply: `你好`
- Sends Enterprise WeChat text content to the configured Coze workflow
- Sends the Coze workflow reply back to the same Enterprise WeChat user
The old internal agent orchestration code has been removed. Future intelligent
responses should be connected through Coze while keeping this callback entrypoint.
The old internal agent orchestration code has been removed. Intelligent
responses now go through Coze while keeping this callback entrypoint.
## Coze Workflows
Configure multiple Coze workflow agents under `foundation.coze.workflows`.
Business code calls them by key, for example `wecom-default`.
```yaml
foundation:
coze:
default-workflow: wecom-default
workflows:
wecom-default:
workflow-id: "7661923520095862811"
app-id: "7661851003527954473"
parameters: {}
```
### Required env vars
- `DEEPSEEK_API_KEY`
@ -22,6 +38,7 @@ responses should be connected through Coze while keeping this callback entrypoin
- `WECOM_AGENT_ID`
- `WECOM_SECRET`
- `WECOM_TOKEN`
- `COZE_API_KEY`
### WeCom backend settings
1. Create a WeCom self-built app.

View File

@ -10,12 +10,14 @@ public class FoundationProperties {
private final Auth auth = new Auth();
private final ModelRouter modelRouter = new ModelRouter();
private final Deepseek deepseek = new Deepseek();
private final Coze coze = new Coze();
private final Wecom wecom = new Wecom();
private final MiniProgram miniProgram = new MiniProgram();
public Auth getAuth() { return auth; }
public ModelRouter getModelRouter() { return modelRouter; }
public Deepseek getDeepseek() { return deepseek; }
public Coze getCoze() { return coze; }
public Wecom getWecom() { return wecom; }
public MiniProgram getMiniProgram() { return miniProgram; }
@ -53,6 +55,44 @@ public class FoundationProperties {
public void setMaxRetries(int maxRetries) { this.maxRetries = maxRetries; }
}
public static class Coze {
private String baseUrl = "https://api.coze.cn";
private String chatPath = "/v1/workflows/chat";
private String apiKey;
private String defaultWorkflow = "wecom-default";
private Map<String, Workflow> workflows = new HashMap<>();
private int readTimeoutMs = 60000;
private int maxRetries = 2;
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
public String getChatPath() { return chatPath; }
public void setChatPath(String chatPath) { this.chatPath = chatPath; }
public String getApiKey() { return apiKey; }
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
public String getDefaultWorkflow() { return defaultWorkflow; }
public void setDefaultWorkflow(String defaultWorkflow) { this.defaultWorkflow = defaultWorkflow; }
public Map<String, Workflow> getWorkflows() { return workflows; }
public void setWorkflows(Map<String, Workflow> workflows) { this.workflows = workflows; }
public int getReadTimeoutMs() { return readTimeoutMs; }
public void setReadTimeoutMs(int readTimeoutMs) { this.readTimeoutMs = readTimeoutMs; }
public int getMaxRetries() { return maxRetries; }
public void setMaxRetries(int maxRetries) { this.maxRetries = maxRetries; }
public static class Workflow {
private String workflowId;
private String appId;
private Map<String, Object> parameters = new HashMap<>();
public String getWorkflowId() { return workflowId; }
public void setWorkflowId(String workflowId) { this.workflowId = workflowId; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public Map<String, Object> getParameters() { return parameters; }
public void setParameters(Map<String, Object> parameters) { this.parameters = parameters; }
}
}
public static class Wecom {
private boolean enabled;
private String corpId;

View File

@ -0,0 +1,36 @@
package com.company.agentfoundation.integration.coze.client;
import com.company.agentfoundation.config.FoundationProperties;
import com.company.agentfoundation.integration.coze.dto.CozeWorkflowRequest;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.util.retry.Retry;
import java.time.Duration;
@Component
public class CozeWorkflowClient {
private final FoundationProperties properties;
private final WebClient webClient;
public CozeWorkflowClient(FoundationProperties properties, WebClient.Builder builder) {
this.properties = properties;
this.webClient = builder.baseUrl(properties.getCoze().getBaseUrl())
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
public JsonNode chat(CozeWorkflowRequest request) {
return webClient.post()
.uri(properties.getCoze().getChatPath())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.getCoze().getApiKey())
.bodyValue(request)
.retrieve()
.bodyToMono(JsonNode.class)
.retryWhen(Retry.backoff(properties.getCoze().getMaxRetries(), Duration.ofMillis(300)))
.block(Duration.ofMillis(properties.getCoze().getReadTimeoutMs()));
}
}

View File

@ -0,0 +1,8 @@
package com.company.agentfoundation.integration.coze.dto;
public record CozeMessage(
String content_type,
String role,
String type,
String content
) {}

View File

@ -0,0 +1,11 @@
package com.company.agentfoundation.integration.coze.dto;
import java.util.List;
import java.util.Map;
public record CozeWorkflowRequest(
String workflow_id,
String app_id,
Map<String, Object> parameters,
List<CozeMessage> additional_messages
) {}

View File

@ -0,0 +1,171 @@
package com.company.agentfoundation.integration.coze.service;
import com.company.agentfoundation.config.FoundationProperties;
import com.company.agentfoundation.integration.coze.client.CozeWorkflowClient;
import com.company.agentfoundation.integration.coze.dto.CozeMessage;
import com.company.agentfoundation.integration.coze.dto.CozeWorkflowRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
public class CozeWorkflowService {
private static final Logger log = LoggerFactory.getLogger(CozeWorkflowService.class);
private final FoundationProperties properties;
private final CozeWorkflowClient client;
private final ObjectMapper objectMapper;
public CozeWorkflowService(FoundationProperties properties, CozeWorkflowClient client, ObjectMapper objectMapper) {
this.properties = properties;
this.client = client;
this.objectMapper = objectMapper;
}
public String chatDefault(String userText) {
return chat(properties.getCoze().getDefaultWorkflow(), userText);
}
public String chat(String workflowKey, String userText) {
return chat(workflowKey, userText, Map.of());
}
public String chat(String workflowKey, String userText, Map<String, Object> parameters) {
validateBaseConfig();
FoundationProperties.Coze.Workflow workflow = workflow(workflowKey);
Map<String, Object> mergedParameters = new HashMap<>();
if (workflow.getParameters() != null) {
mergedParameters.putAll(workflow.getParameters());
}
if (parameters != null) {
mergedParameters.putAll(parameters);
}
CozeWorkflowRequest request = new CozeWorkflowRequest(
workflow.getWorkflowId(),
workflow.getAppId(),
mergedParameters,
List.of(new CozeMessage("text", "user", "question", userText == null ? "" : userText))
);
JsonNode response = client.chat(request);
String reply = extractReply(response).orElse("");
log.info("coze workflow chat success workflowKey={} replyPreview={}", workflowKey, preview(reply));
return reply;
}
private FoundationProperties.Coze.Workflow workflow(String workflowKey) {
String safeWorkflowKey = isBlank(workflowKey) ? properties.getCoze().getDefaultWorkflow() : workflowKey.trim();
FoundationProperties.Coze.Workflow workflow = properties.getCoze().getWorkflows().get(safeWorkflowKey);
if (workflow == null) {
throw new IllegalArgumentException("Coze workflow is not configured: " + safeWorkflowKey);
}
if (isBlank(workflow.getWorkflowId())) {
throw new IllegalStateException("Coze workflow_id is missing: " + safeWorkflowKey);
}
if (isBlank(workflow.getAppId())) {
throw new IllegalStateException("Coze app_id is missing: " + safeWorkflowKey);
}
return workflow;
}
private void validateBaseConfig() {
if (isBlank(properties.getCoze().getApiKey())) {
throw new IllegalStateException("COZE_API_KEY is missing");
}
if (properties.getCoze().getWorkflows() == null || properties.getCoze().getWorkflows().isEmpty()) {
throw new IllegalStateException("No Coze workflows configured");
}
}
private Optional<String> extractReply(JsonNode response) {
if (response == null || response.isNull()) {
return Optional.empty();
}
Optional<String> direct = firstText(response, "answer", "content", "output", "text");
if (direct.isPresent()) {
return direct;
}
Optional<String> nested = extractFromMessages(response.get("messages"))
.or(() -> extractFromMessages(response.path("data").get("messages")))
.or(() -> extractReply(response.get("data")));
if (nested.isPresent()) {
return nested;
}
if (response.isTextual()) {
String text = response.asText();
if (isJson(text)) {
try {
return extractReply(objectMapper.readTree(text));
} catch (Exception e) {
log.warn("coze response text json parse failed err={}", e.getMessage());
}
}
return isBlank(text) ? Optional.empty() : Optional.of(text.trim());
}
return Optional.empty();
}
private Optional<String> extractFromMessages(JsonNode messages) {
if (messages == null || !messages.isArray()) {
return Optional.empty();
}
for (JsonNode message : messages) {
String role = message.path("role").asText("");
String type = message.path("type").asText("");
if ("assistant".equalsIgnoreCase(role) || "answer".equalsIgnoreCase(type)) {
Optional<String> content = firstText(message, "content", "text", "answer", "output");
if (content.isPresent()) {
return content;
}
}
}
for (JsonNode message : messages) {
Optional<String> content = firstText(message, "content", "text", "answer", "output");
if (content.isPresent()) {
return content;
}
}
return Optional.empty();
}
private Optional<String> firstText(JsonNode node, String... fields) {
if (node == null || node.isNull()) {
return Optional.empty();
}
for (String field : fields) {
JsonNode value = node.get(field);
if (value != null && value.isTextual() && !isBlank(value.asText())) {
return Optional.of(value.asText().trim());
}
}
return Optional.empty();
}
private boolean isJson(String text) {
String trimmed = text == null ? "" : text.trim();
return (trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"));
}
private boolean isBlank(String text) {
return text == null || text.isBlank();
}
private String preview(String text) {
if (text == null || text.isBlank()) {
return "";
}
return text.length() <= 80 ? text : text.substring(0, 80) + "...";
}
}

View File

@ -1,5 +1,6 @@
package com.company.agentfoundation.integration.wecom.controller;
import com.company.agentfoundation.integration.coze.service.CozeWorkflowService;
import com.company.agentfoundation.integration.wecom.client.WecomClient;
import com.company.agentfoundation.integration.wecom.model.WecomEncryptedEnvelope;
import com.company.agentfoundation.integration.wecom.model.WecomIncomingMessage;
@ -26,15 +27,18 @@ public class WecomCallbackController {
private static final Logger log = LoggerFactory.getLogger(WecomCallbackController.class);
private static final long MESSAGE_DEDUP_TTL_SECONDS = 600;
private static final String FIXED_REPLY = "\u4f60\u597d";
private final CozeWorkflowService cozeWorkflowService;
private final WecomClient wecomClient;
private final WecomCryptoService wecomCryptoService;
private final XmlMapper xmlMapper = new XmlMapper();
private final Map<String, Long> processedMessages = new ConcurrentHashMap<>();
public WecomCallbackController(
CozeWorkflowService cozeWorkflowService,
WecomClient wecomClient,
WecomCryptoService wecomCryptoService
) {
this.cozeWorkflowService = cozeWorkflowService;
this.wecomClient = wecomClient;
this.wecomCryptoService = wecomCryptoService;
}
@ -88,8 +92,19 @@ public class WecomCallbackController {
return "success";
}
log.info("wecom callback sending fixed reply toUser={} replyPreview={}", msg.getFromUserName(), preview(FIXED_REPLY));
wecomClient.sendText(msg.getFromUserName(), FIXED_REPLY);
String reply;
try {
reply = cozeWorkflowService.chatDefault(userText);
} catch (Exception e) {
log.error("wecom callback coze workflow failed fromUser={} err={}", msg.getFromUserName(), e.getMessage(), e);
reply = FIXED_REPLY;
}
if (reply == null || reply.isBlank()) {
log.warn("wecom callback coze reply blank, fallback to fixed reply");
reply = FIXED_REPLY;
}
log.info("wecom callback sending coze reply toUser={} replyPreview={}", msg.getFromUserName(), preview(reply));
wecomClient.sendText(msg.getFromUserName(), reply);
log.info("wecom callback reply sent toUser={}", msg.getFromUserName());
return "success";
}

View File

@ -35,6 +35,18 @@ foundation:
api-key: sk-b008441047f24558bd4e4cea817206c3
read-timeout-ms: 60000
max-retries: 2
coze:
base-url: https://api.coze.cn
chat-path: /v1/workflows/chat
api-key: ${COZE_API_KEY:cztei_hTd3jrof7F2fpRM4rVvNiK1ipxaQl4rlvQNHKBDK1RlTgnyFO9UqwShkr8IXcuVrk}
default-workflow: wecom-default
workflows:
wecom-default:
workflow-id: "7661923520095862811"
app-id: "7661851003527954473"
parameters: {}
read-timeout-ms: 60000
max-retries: 2
# wecom:
# enabled: true
# corp-id: wwaec174f56094f2b5