feat(channel): add mci-channel-wechat-personal adapter
- New module: mci-channel-wechat-personal - Implements ChannelAdapter SPI for personal WeChat - Calls Python channel-service pywechat HTTP APIs: - /v1/wechat-personal/login/start for initAccount QR code - /v1/wechat-personal/send/text and /send/image for sendMessage - Configurable base URL via sino.channel.wechat-personal.base-url - Added MockWebServer unit tests - Wired module into backend parent and mci-server
This commit is contained in:
49
backend/mci-channel-wechat-personal/pom.xml
Normal file
49
backend/mci-channel-wechat-personal/pom.xml
Normal file
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.sino</groupId>
|
||||
<artifactId>sino-mci-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>mci-channel-wechat-personal</artifactId>
|
||||
<name>mci-channel-wechat-personal</name>
|
||||
<description>Personal WeChat channel adapter</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.sino</groupId>
|
||||
<artifactId>mci-channel-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<version>4.12.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.sino.mci.channel.wechat.personal;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(WechatPersonalProperties.class)
|
||||
public class WechatPersonalAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public WechatPersonalChannelAdapter wechatPersonalChannelAdapter(WechatPersonalProperties properties) {
|
||||
return new WechatPersonalChannelAdapter(properties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.sino.mci.channel.wechat.personal;
|
||||
|
||||
import com.sino.mci.channel.common.dto.CallbackPayload;
|
||||
import com.sino.mci.channel.common.dto.ChannelInitRequest;
|
||||
import com.sino.mci.channel.common.dto.ChannelInitResult;
|
||||
import com.sino.mci.channel.common.dto.SendMessageRequest;
|
||||
import com.sino.mci.channel.common.dto.SendMessageResult;
|
||||
import com.sino.mci.channel.common.model.ChannelType;
|
||||
import com.sino.mci.channel.common.spi.ChannelAdapter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Slf4j
|
||||
public class WechatPersonalChannelAdapter implements ChannelAdapter {
|
||||
|
||||
private static final ParameterizedTypeReference<Map<String, Object>> MAP_TYPE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
|
||||
private final WechatPersonalProperties properties;
|
||||
|
||||
private RestClient restClient;
|
||||
|
||||
public WechatPersonalChannelAdapter(WechatPersonalProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
private RestClient restClient() {
|
||||
if (restClient == null) {
|
||||
restClient = RestClient.builder().baseUrl(properties.getBaseUrl()).build();
|
||||
}
|
||||
return restClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChannelType getChannelType() {
|
||||
return ChannelType.WECHAT_PERSONAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChannelInitResult initAccount(ChannelInitRequest request) {
|
||||
ChannelInitResult result = new ChannelInitResult();
|
||||
if (request == null || request.getConfig() == null) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信初始化失败:请求或配置为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
String accountId = getString(request.getConfig(), "account_id");
|
||||
if (isBlank(accountId)) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信初始化失败:缺少 account_id");
|
||||
return result;
|
||||
}
|
||||
|
||||
String accountName = getString(request.getConfig(), "account_name");
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("account_id", accountId);
|
||||
body.put("account_name", accountName);
|
||||
|
||||
try {
|
||||
// Python 侧 login/start 会 get_or_create 账号,因此该调用同时完成注册校验并返回二维码
|
||||
Map<String, Object> response = post("/v1/wechat-personal/login/start", body);
|
||||
if (response == null) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信初始化失败:Python 服务返回为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
String qrCodeUrl = getString(response, "qr_code_url");
|
||||
if (isBlank(qrCodeUrl)) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信初始化失败:Python 服务未返回二维码");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.setSuccess(true);
|
||||
result.setAccountId(accountId);
|
||||
result.setAccountName(isBlank(accountName) ? accountId : accountName);
|
||||
result.setQrCodeUrl(qrCodeUrl);
|
||||
result.setMessage("个人微信账号初始化成功,请扫描二维码登录");
|
||||
} catch (RestClientException e) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信初始化失败:" + e.getMessage());
|
||||
log.warn("[WechatPersonal] 初始化账号失败", e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageResult sendMessage(SendMessageRequest request) {
|
||||
SendMessageResult result = new SendMessageResult();
|
||||
if (request == null) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("发送请求为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
String accountId = resolveAccountId(request);
|
||||
if (isBlank(accountId)) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信发送失败:缺少 account_id");
|
||||
return result;
|
||||
}
|
||||
|
||||
String conversationId = defaultString(request.getConversationId());
|
||||
if (isBlank(conversationId)) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信发送失败:缺少 conversation_id");
|
||||
return result;
|
||||
}
|
||||
|
||||
String contentType = defaultString(request.getContentType()).toLowerCase();
|
||||
try {
|
||||
Map<String, Object> response;
|
||||
if ("image".equals(contentType)) {
|
||||
response = sendImage(accountId, conversationId, request.getContent());
|
||||
} else {
|
||||
response = sendText(accountId, conversationId, request.getContent());
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信发送失败:Python 服务返回为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
Boolean success = getBoolean(response, "success");
|
||||
if (!Boolean.TRUE.equals(success)) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信发送失败:" + getString(response, "error"));
|
||||
return result;
|
||||
}
|
||||
|
||||
result.setSuccess(true);
|
||||
result.setChannelMessageId(getString(response, "message_id"));
|
||||
} catch (RestClientException e) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("个人微信发送失败:" + e.getMessage());
|
||||
log.warn("[WechatPersonal] 发送消息失败", e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CallbackPayload parseCallback(String rawBody) {
|
||||
CallbackPayload payload = new CallbackPayload();
|
||||
payload.setChannelType(ChannelType.WECHAT_PERSONAL.name());
|
||||
payload.setRawBody(rawBody);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private Map<String, Object> sendText(String accountId, String conversationId, Map<String, Object> content) {
|
||||
String text = getString(content, "text");
|
||||
if (isBlank(text)) {
|
||||
throw new IllegalArgumentException("文本内容为空");
|
||||
}
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("account_id", accountId);
|
||||
body.put("conversation_id", conversationId);
|
||||
body.put("text", text);
|
||||
return post("/v1/wechat-personal/send/text", body);
|
||||
}
|
||||
|
||||
private Map<String, Object> sendImage(String accountId, String conversationId, Map<String, Object> content) {
|
||||
String imageUrl = getString(content, "image_url");
|
||||
if (isBlank(imageUrl)) {
|
||||
throw new IllegalArgumentException("图片地址为空");
|
||||
}
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("account_id", accountId);
|
||||
body.put("conversation_id", conversationId);
|
||||
body.put("image_url", imageUrl);
|
||||
return post("/v1/wechat-personal/send/image", body);
|
||||
}
|
||||
|
||||
private Map<String, Object> post(String uri, Map<String, Object> body) {
|
||||
return restClient().post()
|
||||
.uri(uri)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(MAP_TYPE);
|
||||
}
|
||||
|
||||
private String resolveAccountId(SendMessageRequest request) {
|
||||
String accountId = getString(request.getExtra(), "account_id");
|
||||
if (isNotBlank(accountId)) {
|
||||
return accountId;
|
||||
}
|
||||
return getString(request.getContent(), "account_id");
|
||||
}
|
||||
|
||||
private String getString(Map<?, ?> map, String key) {
|
||||
if (map == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = map.get(key);
|
||||
return value == null ? null : Objects.toString(value);
|
||||
}
|
||||
|
||||
private Boolean getBoolean(Map<?, ?> map, String key) {
|
||||
if (map == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = map.get(key);
|
||||
if (value instanceof Boolean b) {
|
||||
return b;
|
||||
}
|
||||
if (value instanceof String s) {
|
||||
return "true".equalsIgnoreCase(s);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private boolean isNotBlank(String value) {
|
||||
return !isBlank(value);
|
||||
}
|
||||
|
||||
private String defaultString(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.sino.mci.channel.wechat.personal;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "sino.channel.wechat-personal")
|
||||
public class WechatPersonalProperties {
|
||||
|
||||
/**
|
||||
* Python channel-service 基础地址。
|
||||
*/
|
||||
private String baseUrl = "http://mci-channel-service:8000";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.sino.mci.channel.wechat.personal.WechatPersonalAutoConfiguration
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.sino.mci.channel.wechat.personal;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sino.mci.channel.common.dto.ChannelInitRequest;
|
||||
import com.sino.mci.channel.common.dto.ChannelInitResult;
|
||||
import com.sino.mci.channel.common.dto.SendMessageRequest;
|
||||
import com.sino.mci.channel.common.dto.SendMessageResult;
|
||||
import com.sino.mci.channel.common.model.ChannelType;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class WechatPersonalChannelAdapterTest {
|
||||
|
||||
private MockWebServer mockWebServer;
|
||||
private WechatPersonalChannelAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
mockWebServer = new MockWebServer();
|
||||
mockWebServer.start();
|
||||
|
||||
WechatPersonalProperties properties = new WechatPersonalProperties();
|
||||
properties.setBaseUrl(mockWebServer.url("/").toString());
|
||||
|
||||
adapter = new WechatPersonalChannelAdapter(properties);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
mockWebServer.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnChannelType() {
|
||||
assertThat(adapter.getChannelType()).isEqualTo(ChannelType.WECHAT_PERSONAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInitAccountAndReturnQrCodeUrl() throws InterruptedException {
|
||||
mockWebServer.enqueue(new MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(toJson(Map.of(
|
||||
"login_id", "login-123",
|
||||
"qr_code_url", "https://example.com/qr.png",
|
||||
"status", "pending"))));
|
||||
|
||||
ChannelInitRequest request = new ChannelInitRequest();
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("account_id", "wx-123");
|
||||
config.put("account_name", "Test Account");
|
||||
request.setConfig(config);
|
||||
|
||||
ChannelInitResult result = adapter.initAccount(request);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.getAccountId()).isEqualTo("wx-123");
|
||||
assertThat(result.getAccountName()).isEqualTo("Test Account");
|
||||
assertThat(result.getQrCodeUrl()).isEqualTo("https://example.com/qr.png");
|
||||
|
||||
RecordedRequest recorded = mockWebServer.takeRequest();
|
||||
assertThat(recorded.getMethod()).isEqualTo("POST");
|
||||
assertThat(recorded.getPath()).isEqualTo("/v1/wechat-personal/login/start");
|
||||
assertThat(recorded.getBody().readUtf8()).contains("wx-123", "Test Account");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailInitAccountWhenQrCodeMissing() {
|
||||
mockWebServer.enqueue(new MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(toJson(Map.of(
|
||||
"login_id", "login-123",
|
||||
"status", "error"))));
|
||||
|
||||
ChannelInitRequest request = new ChannelInitRequest();
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("account_id", "wx-123");
|
||||
request.setConfig(config);
|
||||
|
||||
ChannelInitResult result = adapter.initAccount(request);
|
||||
|
||||
assertThat(result.isSuccess()).isFalse();
|
||||
assertThat(result.getMessage()).contains("未返回二维码");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSendTextMessage() throws InterruptedException {
|
||||
mockWebServer.enqueue(new MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(toJson(Map.of(
|
||||
"success", true,
|
||||
"message_id", "msg-123"))));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setConversationId("chat-123");
|
||||
request.setContentType("text");
|
||||
Map<String, Object> content = new HashMap<>();
|
||||
content.put("text", "hello");
|
||||
request.setContent(content);
|
||||
Map<String, Object> extra = new HashMap<>();
|
||||
extra.put("account_id", "wx-123");
|
||||
request.setExtra(extra);
|
||||
|
||||
SendMessageResult result = adapter.sendMessage(request);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.getChannelMessageId()).isEqualTo("msg-123");
|
||||
|
||||
RecordedRequest recorded = mockWebServer.takeRequest();
|
||||
assertThat(recorded.getMethod()).isEqualTo("POST");
|
||||
assertThat(recorded.getPath()).isEqualTo("/v1/wechat-personal/send/text");
|
||||
String body = recorded.getBody().readUtf8();
|
||||
assertThat(body).contains("wx-123", "chat-123", "hello");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSendImageMessage() throws InterruptedException {
|
||||
mockWebServer.enqueue(new MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(toJson(Map.of(
|
||||
"success", true,
|
||||
"message_id", "img-123"))));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setConversationId("chat-456");
|
||||
request.setContentType("image");
|
||||
Map<String, Object> content = new HashMap<>();
|
||||
content.put("image_url", "https://example.com/img.png");
|
||||
request.setContent(content);
|
||||
Map<String, Object> extra = new HashMap<>();
|
||||
extra.put("account_id", "wx-123");
|
||||
request.setExtra(extra);
|
||||
|
||||
SendMessageResult result = adapter.sendMessage(request);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.getChannelMessageId()).isEqualTo("img-123");
|
||||
|
||||
RecordedRequest recorded = mockWebServer.takeRequest();
|
||||
assertThat(recorded.getPath()).isEqualTo("/v1/wechat-personal/send/image");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnFailureWhenPythonReportsError() {
|
||||
mockWebServer.enqueue(new MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(toJson(Map.of(
|
||||
"success", false,
|
||||
"error", "account wx-123 not registered"))));
|
||||
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setConversationId("chat-123");
|
||||
request.setContentType("text");
|
||||
Map<String, Object> content = new HashMap<>();
|
||||
content.put("text", "hello");
|
||||
request.setContent(content);
|
||||
Map<String, Object> extra = new HashMap<>();
|
||||
extra.put("account_id", "wx-123");
|
||||
request.setExtra(extra);
|
||||
|
||||
SendMessageResult result = adapter.sendMessage(request);
|
||||
|
||||
assertThat(result.isSuccess()).isFalse();
|
||||
assertThat(result.getMessage()).contains("account wx-123 not registered");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnFailureWhenAccountIdMissing() {
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setConversationId("chat-123");
|
||||
request.setContentType("text");
|
||||
request.setContent(Map.of("text", "hello"));
|
||||
|
||||
SendMessageResult result = adapter.sendMessage(request);
|
||||
|
||||
assertThat(result.isSuccess()).isFalse();
|
||||
assertThat(result.getMessage()).contains("account_id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldParseCallback() {
|
||||
assertThat(adapter.parseCallback("raw-body").getChannelType())
|
||||
.isEqualTo(ChannelType.WECHAT_PERSONAL.name());
|
||||
assertThat(adapter.parseCallback("raw-body").getRawBody()).isEqualTo("raw-body");
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
try {
|
||||
return new ObjectMapper().writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@
|
||||
<groupId>com.sino</groupId>
|
||||
<artifactId>mci-channel-wecom</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.sino</groupId>
|
||||
<artifactId>mci-channel-wechat-personal</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<module>mci-shared</module>
|
||||
<module>mci-channel-common</module>
|
||||
<module>mci-channel-wecom</module>
|
||||
<module>mci-channel-wechat-personal</module>
|
||||
<module>mci-server</module>
|
||||
</modules>
|
||||
|
||||
@@ -50,6 +51,11 @@
|
||||
<artifactId>mci-channel-wecom</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.sino</groupId>
|
||||
<artifactId>mci-channel-wechat-personal</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-boot-starter</artifactId>
|
||||
|
||||
Reference in New Issue
Block a user