对接扣子
This commit is contained in:
parent
f057ca5053
commit
dc08bb52d5
23
README.md
23
README.md
@ -11,10 +11,26 @@ Java Spring Boot service for Enterprise WeChat callbacks and internal APIs.
|
|||||||
- Verifies Enterprise WeChat callback signatures
|
- Verifies Enterprise WeChat callback signatures
|
||||||
- Decrypts encrypted callback payloads when safe mode is enabled
|
- Decrypts encrypted callback payloads when safe mode is enabled
|
||||||
- Deduplicates text messages
|
- 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
|
The old internal agent orchestration code has been removed. Intelligent
|
||||||
responses should be connected through Coze while keeping this callback entrypoint.
|
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
|
### Required env vars
|
||||||
- `DEEPSEEK_API_KEY`
|
- `DEEPSEEK_API_KEY`
|
||||||
@ -22,6 +38,7 @@ responses should be connected through Coze while keeping this callback entrypoin
|
|||||||
- `WECOM_AGENT_ID`
|
- `WECOM_AGENT_ID`
|
||||||
- `WECOM_SECRET`
|
- `WECOM_SECRET`
|
||||||
- `WECOM_TOKEN`
|
- `WECOM_TOKEN`
|
||||||
|
- `COZE_API_KEY`
|
||||||
|
|
||||||
### WeCom backend settings
|
### WeCom backend settings
|
||||||
1. Create a WeCom self-built app.
|
1. Create a WeCom self-built app.
|
||||||
|
|||||||
@ -10,12 +10,14 @@ public class FoundationProperties {
|
|||||||
private final Auth auth = new Auth();
|
private final Auth auth = new Auth();
|
||||||
private final ModelRouter modelRouter = new ModelRouter();
|
private final ModelRouter modelRouter = new ModelRouter();
|
||||||
private final Deepseek deepseek = new Deepseek();
|
private final Deepseek deepseek = new Deepseek();
|
||||||
|
private final Coze coze = new Coze();
|
||||||
private final Wecom wecom = new Wecom();
|
private final Wecom wecom = new Wecom();
|
||||||
private final MiniProgram miniProgram = new MiniProgram();
|
private final MiniProgram miniProgram = new MiniProgram();
|
||||||
|
|
||||||
public Auth getAuth() { return auth; }
|
public Auth getAuth() { return auth; }
|
||||||
public ModelRouter getModelRouter() { return modelRouter; }
|
public ModelRouter getModelRouter() { return modelRouter; }
|
||||||
public Deepseek getDeepseek() { return deepseek; }
|
public Deepseek getDeepseek() { return deepseek; }
|
||||||
|
public Coze getCoze() { return coze; }
|
||||||
public Wecom getWecom() { return wecom; }
|
public Wecom getWecom() { return wecom; }
|
||||||
public MiniProgram getMiniProgram() { return miniProgram; }
|
public MiniProgram getMiniProgram() { return miniProgram; }
|
||||||
|
|
||||||
@ -53,6 +55,44 @@ public class FoundationProperties {
|
|||||||
public void setMaxRetries(int maxRetries) { this.maxRetries = maxRetries; }
|
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 {
|
public static class Wecom {
|
||||||
private boolean enabled;
|
private boolean enabled;
|
||||||
private String corpId;
|
private String corpId;
|
||||||
|
|||||||
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.company.agentfoundation.integration.coze.dto;
|
||||||
|
|
||||||
|
public record CozeMessage(
|
||||||
|
String content_type,
|
||||||
|
String role,
|
||||||
|
String type,
|
||||||
|
String content
|
||||||
|
) {}
|
||||||
@ -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
|
||||||
|
) {}
|
||||||
@ -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) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package com.company.agentfoundation.integration.wecom.controller;
|
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.client.WecomClient;
|
||||||
import com.company.agentfoundation.integration.wecom.model.WecomEncryptedEnvelope;
|
import com.company.agentfoundation.integration.wecom.model.WecomEncryptedEnvelope;
|
||||||
import com.company.agentfoundation.integration.wecom.model.WecomIncomingMessage;
|
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 Logger log = LoggerFactory.getLogger(WecomCallbackController.class);
|
||||||
private static final long MESSAGE_DEDUP_TTL_SECONDS = 600;
|
private static final long MESSAGE_DEDUP_TTL_SECONDS = 600;
|
||||||
private static final String FIXED_REPLY = "\u4f60\u597d";
|
private static final String FIXED_REPLY = "\u4f60\u597d";
|
||||||
|
private final CozeWorkflowService cozeWorkflowService;
|
||||||
private final WecomClient wecomClient;
|
private final WecomClient wecomClient;
|
||||||
private final WecomCryptoService wecomCryptoService;
|
private final WecomCryptoService wecomCryptoService;
|
||||||
private final XmlMapper xmlMapper = new XmlMapper();
|
private final XmlMapper xmlMapper = new XmlMapper();
|
||||||
private final Map<String, Long> processedMessages = new ConcurrentHashMap<>();
|
private final Map<String, Long> processedMessages = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public WecomCallbackController(
|
public WecomCallbackController(
|
||||||
|
CozeWorkflowService cozeWorkflowService,
|
||||||
WecomClient wecomClient,
|
WecomClient wecomClient,
|
||||||
WecomCryptoService wecomCryptoService
|
WecomCryptoService wecomCryptoService
|
||||||
) {
|
) {
|
||||||
|
this.cozeWorkflowService = cozeWorkflowService;
|
||||||
this.wecomClient = wecomClient;
|
this.wecomClient = wecomClient;
|
||||||
this.wecomCryptoService = wecomCryptoService;
|
this.wecomCryptoService = wecomCryptoService;
|
||||||
}
|
}
|
||||||
@ -88,8 +92,19 @@ public class WecomCallbackController {
|
|||||||
return "success";
|
return "success";
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("wecom callback sending fixed reply toUser={} replyPreview={}", msg.getFromUserName(), preview(FIXED_REPLY));
|
String reply;
|
||||||
wecomClient.sendText(msg.getFromUserName(), FIXED_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());
|
log.info("wecom callback reply sent toUser={}", msg.getFromUserName());
|
||||||
return "success";
|
return "success";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,18 @@ foundation:
|
|||||||
api-key: sk-b008441047f24558bd4e4cea817206c3
|
api-key: sk-b008441047f24558bd4e4cea817206c3
|
||||||
read-timeout-ms: 60000
|
read-timeout-ms: 60000
|
||||||
max-retries: 2
|
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:
|
# wecom:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
# corp-id: wwaec174f56094f2b5
|
# corp-id: wwaec174f56094f2b5
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user