跳转到主要内容

ChatClient

基于WebSocket的聊天客户端核心类。负责实际的服务器连接及消息处理。

事件代码

代码常量名说明
0EventChannels频道列表
1EventSubscribe频道订阅
2EventUnSubscribe频道订阅 解除
3EventPublicMessage公开消息
4EventPrivateMessage私密消息
5EventPlayerOnline玩家 在线 状态
97EventNotifyMessage通知消息
99EventError错误

主要功能

方法说明
Connect连接WebSocket服务器
Disconnect断开服务器连接
Subscribe频道订阅 (上一 消息 数量 设置 可用)
Unsubscribe频道订阅 解除
GetChannels频道列表 请求
GetPlayersOnline玩家 在线 状态 查询
SendPublicMessage公开消息 发送
SendPrivateMessage发送私密消息
SetUserName设置用户名
Service主线程回调处理

属性

属性类型说明
IsConnectedbool连接 状态
CurrentChannelFString当前订阅的频道

Unreal C++实现

头文件 (ChatClient.h)

// ChatClient.h
#pragma once

#include "CoreMinimal.h"
#include "IWebSocket.h"
#include "ChatModels.h"

class FChatClient
{
public:
// 이벤트 코드
static const uint8 EventChannels = 0;
static const uint8 EventSubscribe = 1;
static const uint8 EventUnSubscribe = 2;
static const uint8 EventPublicMessage = 3;
static const uint8 EventPrivateMessage = 4;
static const uint8 EventPlayerOnline = 5;
static const uint8 EventNotifyMessage = 97;
static const uint8 EventError = 99;

FChatClient(IChatListener* InListener, const FString& InUserName = TEXT(""));
~FChatClient();

// Public API
void Connect(const FChatServerItem& Server);
void Disconnect();
void Subscribe(const FString& Channel, int32 PrevMessageCount = 0);
void Unsubscribe(const FString& Channel);
void GetChannels();
void GetPlayersOnline(const TArray<FString>& UserIds);
void SendPublicMessage(const FString& Channel, const FString& Text);
void SendPrivateMessage(const FString& TargetUserId, const FString& Text);
void SetUserName(const FString& InUserName);

// Getters
bool GetIsConnected() const { return bIsConnected; }
FString GetCurrentChannel() const { return CurrentChannel; }

private:
void ConnectToServer(const FString& Url);
FString BuildServerUrl(const FString& BaseUrl);
void Send(uint8 Type, const FString& Channel, const FString& Message = TEXT(""),
const FString& PrivateToUserId = TEXT(""), int32 PrevMessageCount = 0);
void ProcessMessage(const FString& Json);
FString ComputeHash();
FString GetLocalIP();

// WebSocket 이벤트 핸들러
void OnConnected();
void OnConnectionError(const FString& Error);
void OnClosed(int32 StatusCode, const FString& Reason, bool bWasClean);
void OnMessage(const FString& Message);

IChatListener* Listener;
TSharedPtr<IWebSocket> WebSocket;
FString UserUniqueId;
FString UserName;
FString CurrentChannel;
bool bIsConnected;
};

实现文件 (ChatClient.cpp)

OpenSSL依赖

使用OpenSSL进行HMAC-SHA256计算。需要在项目的Build.cs文件中添加"OpenSSL"模块。

// ChatClient.cpp
#include "ChatClient.h"
#include "WebSocketsModule.h"
#include "PlayNANOOAuth.h"
#include "Misc/Base64.h"
#include "SocketSubsystem.h"
#include "IPAddress.h"

#if PLATFORM_WINDOWS
#include "Windows/AllowWindowsPlatformTypes.h"
#endif
#include <openssl/hmac.h>
#include <openssl/sha.h>
#if PLATFORM_WINDOWS
#include "Windows/HideWindowsPlatformTypes.h"
#endif

FChatClient::FChatClient(IChatListener* InListener, const FString& InUserName)
: Listener(InListener)
, UserName(InUserName)
, bIsConnected(false)
{
// UUID는 DataManager에서 가져온다고 가정
UserUniqueId = UGameDataManager::Get()->GetAccessToken(); // 또는 별도 UUID 관리
}

FChatClient::~FChatClient()
{
Disconnect();
}

void FChatClient::Connect(const FChatServerItem& Server)
{
if (bIsConnected) return;

FString Protocol = Server.Secure == TEXT("Y") ? TEXT("wss") : TEXT("ws");
FString BaseUrl = FString::Printf(TEXT("%s://%s:%d"), *Protocol, *Server.Addr, Server.Port);
ConnectToServer(BuildServerUrl(BaseUrl));
}

void FChatClient::Disconnect()
{
if (WebSocket.IsValid())
{
bIsConnected = false;
WebSocket->Close();
WebSocket.Reset();
}
}

void FChatClient::Subscribe(const FString& Channel, int32 PrevMessageCount)
{
CurrentChannel = Channel;
Send(EventSubscribe, Channel, TEXT(""), TEXT(""), PrevMessageCount);
}

void FChatClient::Unsubscribe(const FString& Channel)
{
Send(EventUnSubscribe, Channel);
}

void FChatClient::GetChannels()
{
Send(EventChannels, TEXT(""));
}

void FChatClient::GetPlayersOnline(const TArray<FString>& UserIds)
{
if (!WebSocket.IsValid() || !bIsConnected)
{
if (Listener)
Listener->OnError(TEXT("NOT_CONNECTED"), TEXT("WebSocket is not connected"));
return;
}

FChatMessageModel Msg;
Msg.mid = FGuid::NewGuid().ToString();
Msg.type = EventPlayerOnline;
Msg.gameId = FPlayNANOOAuth::GameId;
Msg.serviceKey = FPlayNANOOAuth::ServiceKey;
Msg.userUniqueId = UserUniqueId;
Msg.userName = UserName;
Msg.onlinePlayers = UserIds;
Msg.ipAddr = GetLocalIP();
Msg.createdAt = FDateTime::Now().ToString(TEXT("%Y-%m-%dT%H:%M:%S"));

WebSocket->Send(Msg.ToJson());
}

void FChatClient::SendPublicMessage(const FString& Channel, const FString& Text)
{
Send(EventPublicMessage, Channel, Text);
}

void FChatClient::SendPrivateMessage(const FString& TargetUserId, const FString& Text)
{
Send(EventPrivateMessage, CurrentChannel, Text, TargetUserId);
}

void FChatClient::SetUserName(const FString& InUserName)
{
UserName = InUserName;
}

FString FChatClient::BuildServerUrl(const FString& BaseUrl)
{
FString Hash = ComputeHash();
// Unreal WebSocket은 경로가 없으면 '//'를 요청하는 버그가 있음
// 명시적으로 '/' 경로 추가
return FString::Printf(TEXT("%s/?gameId=%s&uuid=%s&hash=%s"),
*BaseUrl,
*FPlayNANOOAuth::GameId,
*UserUniqueId,
*FGenericPlatformHttp::UrlEncode(Hash));
}

void FChatClient::ConnectToServer(const FString& Url)
{
if (!FModuleManager::Get().IsModuleLoaded(TEXT("WebSockets")))
{
FModuleManager::Get().LoadModule(TEXT("WebSockets"));
}

WebSocket = FWebSocketsModule::Get().CreateWebSocket(Url);

WebSocket->OnConnected().AddLambda([this]()
{
OnConnected();
});

WebSocket->OnConnectionError().AddLambda([this](const FString& Error)
{
OnConnectionError(Error);
});

WebSocket->OnClosed().AddLambda([this](int32 StatusCode, const FString& Reason, bool bWasClean)
{
OnClosed(StatusCode, Reason, bWasClean);
});

WebSocket->OnMessage().AddLambda([this](const FString& Message)
{
OnMessage(Message);
});

WebSocket->Connect();
}

void FChatClient::OnConnected()
{
bIsConnected = true;
if (Listener)
Listener->OnConnected();
}

void FChatClient::OnConnectionError(const FString& Error)
{
if (Listener)
Listener->OnError(TEXT("CONNECTION_FAILED"), Error);
}

void FChatClient::OnClosed(int32 StatusCode, const FString& Reason, bool bWasClean)
{
bIsConnected = false;
if (Listener)
Listener->OnDisconnected();
}

void FChatClient::OnMessage(const FString& Message)
{
ProcessMessage(Message);
}

void FChatClient::Send(uint8 Type, const FString& Channel, const FString& Message,
const FString& PrivateToUserId, int32 PrevMessageCount)
{
if (!WebSocket.IsValid() || !bIsConnected)
{
if (Listener)
Listener->OnError(TEXT("NOT_CONNECTED"), TEXT("WebSocket is not connected"));
return;
}

FChatMessageModel Msg;
Msg.mid = FGuid::NewGuid().ToString();
Msg.type = Type;
Msg.gameId = FPlayNANOOAuth::GameId;
Msg.serviceKey = FPlayNANOOAuth::ServiceKey;
Msg.channelId = Channel;
Msg.userUniqueId = UserUniqueId;
Msg.userName = UserName;
Msg.message = Message;
Msg.privateToUserUniqueId = PrivateToUserId;
Msg.prevMessageCount = PrevMessageCount;
Msg.ipAddr = GetLocalIP();
Msg.createdAt = FDateTime::Now().ToString(TEXT("%Y-%m-%dT%H:%M:%S"));

WebSocket->Send(Msg.ToJson());
}

void FChatClient::ProcessMessage(const FString& Json)
{
UE_LOG(LogTemp, Log, TEXT("[Chat] Received: %s"), *Json);

FChatMessageModel Msg;
if (!Msg.FromJson(Json))
{
UE_LOG(LogTemp, Error, TEXT("[Chat] ProcessMessage error: Failed to parse JSON"));
return;
}

FChatUserInfo User;
User.visitorId = Msg.userUniqueId;
User.visitorName = Msg.userName;

if (!Listener) return;

switch (Msg.type)
{
case EventChannels:
Listener->OnChannels(Msg.channels);
break;
case EventSubscribe:
Listener->OnSubscribed(User);
break;
case EventUnSubscribe:
Listener->OnUnSubscribed(User);
break;
case EventPublicMessage:
Listener->OnPublicMessage(User, Msg.message);
break;
case EventPrivateMessage:
Listener->OnPrivateMessage(User, Msg.message);
break;
case EventPlayerOnline:
Listener->OnPlayerOnline(Msg.players);
break;
case EventNotifyMessage:
if (Msg.userUniqueId != UserUniqueId)
Listener->OnNotifyMessage(User, Msg.message);
break;
case EventError:
Listener->OnError(FString::FromInt(Msg.error), Msg.message);
break;
}
}

FString FChatClient::ComputeHash()
{
// HMAC-SHA256 해시 생성
FString Message = FPlayNANOOAuth::GameId + FPlayNANOOAuth::ServiceKey + UserUniqueId;

// UTF-8로 변환
FTCHARToUTF8 KeyUtf8(*FPlayNANOOAuth::SecretKey);
FTCHARToUTF8 MessageUtf8(*Message);

// HMAC-SHA256 계산 (OpenSSL)
unsigned char Hash[SHA256_DIGEST_LENGTH];
unsigned int HashLen = 0;

HMAC(EVP_sha256(),
KeyUtf8.Get(), KeyUtf8.Length(),
(const unsigned char*)MessageUtf8.Get(), MessageUtf8.Length(),
Hash, &HashLen);

// Base64 인코딩
TArray<uint8> HashArray;
HashArray.Append(Hash, HashLen);
return FBase64::Encode(HashArray);
}

FString FChatClient::GetLocalIP()
{
bool bCanBindAll;
TSharedPtr<FInternetAddr> LocalAddr = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->GetLocalHostAddr(*GLog, bCanBindAll);
if (LocalAddr.IsValid())
{
return LocalAddr->ToString(false);
}
return TEXT("0.0.0.0");
}