| 구성 요소 | 역할 |
|---|---|
| Chat XDK | 암호화, 복호화, 서명, 개인 키 저장(보안 키 백업 또는 키 blob) |
| X API | 공개 키, 대화 키, 메시지, 이벤트—Python 또는 TypeScript XDK, 또는 사용자 액세스 토큰을 사용한 HTTPS를 통해 |
전제 조건
- 개발자 계정과 OAuth 2.0용으로 구성된 앱
dm.read,dm.write,tweet.read,users.read권한이 있는 사용자 액세스 토큰
1. 의존성 설치
- Python
- TypeScript
- Rust
- Go
- C#
- Java
pip install chatxdk xdk
chatxdk이며 chat_xdk로 import합니다. Python 3.10+이 필요합니다.npm install @xdevplatform/chat-xdk @xdevplatform/xdk
npm install juicebox-sdk # optional peer dependency — required for setup()/unlock() secure key backup
@xdevplatform/chat-xdk 내부에 포함되어 있어 별도의 빌드 단계가 없습니다. Node.js 18+가 필요합니다.[dependencies]
# chat-xdk-core is not yet on crates.io — use the git dependency
chat-xdk-core = { git = "https://github.com/xdevplatform/chat-xdk", tag = "v0.4.0" }
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde_json = "1"
base64 = "0.22"
# Required until thrift 0.24 is released on crates.io
[patch.crates-io]
thrift = { git = "https://github.com/apache/thrift.git", rev = "deb36fa409849de45973b04ffc3ce49d277ca90a" }
go get github.com/xdevplatform/chat-xdk/go/chatxdk
dotnet add package XDevPlatform.ChatXdk
<dependency>
<groupId>com.x</groupId>
<artifactId>chatxdk</artifactId>
<version>0.4.0</version>
</dependency>
jna.library.path 설정이 필요 없습니다. com.x.chatxdk에서 import하세요. JDK 17+이 필요합니다.- Python
- TypeScript
- Rust
- Go
- C#
- Java
from xdk import Client
client = Client(access_token="YOUR_OAUTH2_USER_TOKEN")
import { Client } from '@xdevplatform/xdk';
const client = new Client({ accessToken: 'YOUR_OAUTH2_USER_TOKEN' });
let access_token = std::env::var("X_ACCESS_TOKEN")?;
let http = reqwest::blocking::Client::new();
let auth = format!("Bearer {access_token}");
accessToken := os.Getenv("X_ACCESS_TOKEN")
httpClient := &http.Client{Timeout: 30 * time.Second}
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("X_ACCESS_TOKEN"));
String accessToken = System.getenv("X_ACCESS_TOKEN");
HttpClient http = HttpClient.newHttpClient();
2. 기존 키로 Chat XDK 초기화
이 단계는 이미 가지고 있는 키를 로드합니다—이 아이덴티티가 이전에 최초 설정을 완료한 경우 사용하세요:- 보안 키 백업: 공개 키 레코드의
juicebox_config로 SDK를 구성한 다음 패스코드로unlock하여 개인 키를 복구합니다(예: 새 기기에서). - 키 blob: 이전에
export_keys로 내보낸 blob과 함께 등록된 키 버전을 전달하여import_keys를 호출합니다(Rust와 Go에서는 이 변형을import_keys_with_version/ImportKeysWithVersion이라고 부릅니다).
set_identity(user_id, signing_key_version)**을 사용자 ID와 레코드의 public_key_version으로 한 번 호출합니다. 이는 세션 아이덴티티를 저장합니다: 이후 모든 encrypt와 prepare 호출은 이 아이덴티티로 서명되므로, 호출마다 발신자 ID나 서명 키 버전을 전달할 필요가 없습니다.
처음 설정 중인가요? 동일한 방식으로 SDK를 생성하되 unlock/import_keys를 건너뛰고, 키를 생성하고 백업 및 등록하려면 3단계로 계속 진행하세요.
- Python
- TypeScript
- Rust
- Go
- C#
- Java
import json
from chat_xdk import Chat
resp = client.chat.get_user_public_keys(
"YOUR_USER_ID",
public_key_fields=[
"public_key_version", "public_key", "signing_public_key",
"identity_public_key_signature", "juicebox_config",
],
)
record = resp.data[0]
signing_key_version = str(record["public_key_version"])
chat = Chat(json.dumps(record["juicebox_config"]))
chat.unlock("YOUR_PASSCODE") # recovers keys stored by setup() during first-time setup (step 3)
# Or load a key blob instead of secure key backup:
# chat.import_keys(blob, version=signing_key_version)
chat.set_identity("YOUR_USER_ID", signing_key_version)
import { createChat } from '@xdevplatform/chat-xdk';
const resp = await client.chat.getUserPublicKeys('YOUR_USER_ID', {
publicKeyFields: [
'public_key_version', 'public_key', 'signing_public_key',
'identity_public_key_signature', 'juicebox_config',
],
});
const record = resp.data[0];
const signingKeyVersion = String(record.public_key_version);
const chat = await createChat({
juiceboxConfig: JSON.stringify(record.juicebox_config),
getAuthToken: async (realmId) => getRealmTokenFromYourBackend(realmId),
});
await chat.unlock('YOUR_PASSCODE');
chat.setIdentity('YOUR_USER_ID', signingKeyVersion);
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use chat_xdk_core::ChatCore;
let chat = ChatCore::new();
let blob = B64.decode(std::env::var("PRIVATE_KEYS_B64")?)?;
let signing_key_version = std::env::var("SIGNING_KEY_VERSION").unwrap_or_else(|_| "1".into());
chat.import_keys_with_version(&blob, &signing_key_version)?;
chat.set_identity("YOUR_USER_ID", &signing_key_version);
import "github.com/xdevplatform/chat-xdk/go/chatxdk"
chat := chatxdk.New()
defer chat.Close()
blob, err := chatxdk.Base64ToBytes(os.Getenv("PRIVATE_KEYS_B64"))
if err != nil {
log.Fatal(err)
}
signingKeyVersion := os.Getenv("SIGNING_KEY_VERSION")
if signingKeyVersion == "" {
signingKeyVersion = "1"
}
if err := chat.ImportKeysWithVersion(blob, signingKeyVersion); err != nil {
log.Fatal(err)
}
if err := chat.SetIdentity(myUserID, signingKeyVersion); err != nil {
log.Fatal(err)
}
using ChatXdk;
using var chat = new Chat();
var signingKeyVersion = Environment.GetEnvironmentVariable("SIGNING_KEY_VERSION") ?? "1";
chat.ImportKeys(Convert.FromBase64String(
Environment.GetEnvironmentVariable("PRIVATE_KEYS_B64")!), signingKeyVersion);
chat.SetIdentity(myUserId, signingKeyVersion);
import com.x.chatxdk.Chat;
String signingKeyVersion = Optional.ofNullable(System.getenv("SIGNING_KEY_VERSION")).orElse("1");
try (Chat chat = new Chat()) {
chat.importKeys(Base64.getDecoder().decode(System.getenv("PRIVATE_KEYS_B64")), signingKeyVersion);
chat.setIdentity(myUserId, signingKeyVersion);
}
export_keys / import_keys)을 사용합니다. 클라이언트 앱은 종종 보안 키 백업(패스코드를 사용하는 setup / unlock)을 사용합니다. 두 경로 모두에 대해서는 Chat XDK 레퍼런스를 참고하세요.
자체 키를 가져오시나요?
import_keys는 Chat XDK의 export_keys가 생성한 불투명 blob만 받습니다—이는 원시나 PEM 인코딩된 P-256 키가 아니라, 전체 키 상태의 버전 관리된 비공개 직렬화입니다. 이 blob을 직접 만들 수는 없습니다: generate_keypairs(3단계)로 키를 생성하고, blob을 한 번 내보내어 base64로 인코딩하여 저장하세요. 수작업으로 만들거나 수정된 blob은 import에 실패합니다.3. 키 생성 및 등록(최초 설정)
2단계에서 기존 키를 로드한 경우 이 단계를 건너뛰세요. 그렇지 않은 경우, 새 아이덴티티에 대한 일회성 설정은 세 가지를 수행합니다:- 키쌍 생성 —
generate_keypairs가 아이덴티티 및 서명 키쌍을 생성합니다. - 개인 키 저장 — 패스코드로
setup을 호출하면 보안 키 백업에 저장하고(클라이언트),export_keys는 안전하게 저장할 키 blob을 반환합니다(서버 및 봇). - 공개 키 등록 — add-public-key 엔드포인트에 등록 페이로드를 POST하여 다른 사람이 당신에게 암호화하고 당신의 서명을 검증할 수 있게 합니다.
set_identity를 호출하여 마무리하면, 이 세션은 새 아이덴티티로 서명합니다.
모든 바인딩(Python, TypeScript, Go, Rust, C#, Java)에 대한 바로 실행 가능한 일회성 등록 스크립트가
chat-xdk/examples에 있습니다. 새 아이덴티티를 온보딩하기만 하면 될 때는 아래 흐름을 손수 구현하는 대신 이를 사용하세요.- Python
- TypeScript
- Rust
- Go
- C#
- Java
from xdk.chat.models import AddUserPublicKeyRequest
registration = chat.generate_keypairs()
pk = registration.public_key
client.chat.add_user_public_key(
"YOUR_USER_ID",
AddUserPublicKeyRequest(
public_key={
"identity_public_key_signature": pk.identity_public_key_signature,
"public_key": pk.public_key,
"public_key_fingerprint": pk.public_key_fingerprint,
"registration_method": pk.registration_method,
"signing_public_key": pk.signing_public_key,
"signing_public_key_signature": pk.signing_public_key_signature,
},
version=registration.version,
generate_version=registration.generate_version,
),
)
chat.setup("YOUR_PASSCODE")
chat.set_identity("YOUR_USER_ID", str(registration.version or "1"))
const registration = chat.generateKeypairs();
const pk = registration.publicKey;
await client.chat.addUserPublicKey('YOUR_USER_ID', {
public_key: {
identity_public_key_signature: pk.identityPublicKeySignature,
public_key: pk.publicKey,
public_key_fingerprint: pk.publicKeyFingerprint,
registration_method: pk.registrationMethod,
signing_public_key: pk.signingPublicKey,
signing_public_key_signature: pk.signingPublicKeySignature,
},
version: registration.version,
generate_version: registration.generateVersion,
});
await chat.setup('YOUR_PASSCODE');
chat.setIdentity('YOUR_USER_ID', String(registration.version ?? '1'));
let registration = chat.generate_keypairs()?;
let body = serde_json::to_value(®istration)?;
let resp = http
.post(format!("https://api.x.com/2/users/{user_id}/public_keys"))
.header("Authorization", &auth)
.json(&body)
.send()?;
if !resp.status().is_success() {
anyhow::bail!("register keys: {}", resp.text()?);
}
let _blob = chat.export_keys()?; // store securely
let key_version = registration.version.clone().unwrap_or_else(|| "1".into());
chat.set_identity(&user_id, &key_version);
registration, err := chat.GenerateKeypairs()
if err != nil {
log.Fatal(err)
}
regJSON, _ := json.Marshal(registration)
req, _ := http.NewRequest(http.MethodPost,
"https://api.x.com/2/users/"+userID+"/public_keys",
bytes.NewReader(regJSON))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
log.Fatal(err)
}
resp.Body.Close()
privateKeys, _ := chat.ExportKeys() // store securely
_ = privateKeys
keyVersion := "1"
if registration.Version != nil {
keyVersion = *registration.Version
}
if err := chat.SetIdentity(userID, keyVersion); err != nil {
log.Fatal(err)
}
var registration = chat.GenerateKeypairs();
var regJson = System.Text.Json.JsonSerializer.Serialize(registration);
using var content = new StringContent(regJson, Encoding.UTF8, "application/json");
using var regResp = await http.PostAsync(
$"https://api.x.com/2/users/{Uri.EscapeDataString(userId)}/public_keys", content);
regResp.EnsureSuccessStatusCode();
var blob = chat.ExportKeys(); // store securely
chat.SetIdentity(userId, registration.Version ?? "1");
var registration = chat.generateKeypairs();
String regJson = new ObjectMapper().writeValueAsString(registration);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.x.com/2/users/" + userId + "/public_keys"))
.header("Authorization", "Bearer " + accessToken)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(regJson))
.build();
HttpResponse<String> regResp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (regResp.statusCode() >= 300) {
throw new RuntimeException("register keys: " + regResp.body());
}
byte[] blob = chat.exportKeys(); // store securely
chat.setIdentity(myUserId, registration.version != null ? registration.version : "1");
보안 키 백업에는 강력한 패스코드를 사용하세요. 패스코드를 잃거나 보호되지 않은 키 blob을 잃으면 과거 메시지를 복호화하지 못할 수 있습니다.
4. 대화 키 설정
**prepare_conversation_key_change**를 모든 참여자의 아이덴티티 공개 키와 함께 호출합니다. 발신자 아이덴티티는 2단계에서 설정한 세션에서 옵니다. 한 번의 호출로 새 대화 키가 생성되고, 각 참여자에 대해 암호화되며, 변경에 서명이 이루어집니다. 결과를 add conversation keys 엔드포인트(POST /2/chat/conversations/{id}/keys)에 POST하세요—본문은 conversation_key_version, conversation_participant_keys(SDK encrypted_key → API encrypted_conversation_key), 그리고 **action_signatures**가 필요합니다(필수이며, 이것이 없으면 API가 호출을 거부합니다). 전송에 사용할 원시 대화 키는 보관하세요.
응답은 정규 대화 ID(data.conversation_id—1:1의 경우 하이픈으로 연결된 쌍, 그룹의 경우 g가 접두된 ID)와 키 변경의 data.sequence_id를 반환합니다. 이후 요청에서는 클라이언트에서 다시 구성하는 대신 반환된 이 ID를 사용하세요. 나중에 동일한 호출로 키를 순환시킬 수도 있습니다: 기존 대화 ID를 prepare_conversation_key_change에 전달하고 새 키 버전으로 POST하세요. 대화 키가 노출된 것으로 의심되면 순환하세요—순환은 미래 메시지만 보호합니다. 이전 키 버전으로 암호화된 메시지는 그 버전을 가진 누구에게나 여전히 읽을 수 있습니다.
감싸기 전에 가져온 키를 검증하세요.
prepare_conversation_key_change는 전달하는 모든 공개 키에 대해 새 대화 키를 암호화합니다. 각 가져온 레코드를 먼저 verify_key_binding(identity, signing, signature)로 확인하세요—공개 키 API에서 얻은 레코드의 public_key, signing_public_key, identity_public_key_signature 필드를 전달하세요—대체된 아이덴티티 키가 대화 키를 받지 못하도록 합니다.- Python
- TypeScript
- Rust
- Go
- C#
- Java
def public_key_input(user_id: str) -> dict:
r = client.chat.get_user_public_keys(
user_id, public_key_fields=["public_key_version", "public_key"]
).data[0]
return {"user_id": user_id, "public_key": r["public_key"], "key_version": r["public_key_version"]}
prepared = chat.prepare_conversation_key_change(
[public_key_input("YOUR_USER_ID"), public_key_input("RECIPIENT_USER_ID")],
# conversation_id=None for a new 1:1; pass the id to rotate later
)
resp = client.chat.add_conversation_keys(
"RECIPIENT_USER_ID",
{
"conversation_key_version": prepared["conversation_key_version"],
"conversation_participant_keys": [
{
"user_id": pk["user_id"],
"encrypted_conversation_key": pk["encrypted_key"],
"public_key_version": pk["public_key_version"],
}
for pk in prepared["participant_keys"]
],
"action_signatures": [
{
"message_id": sig["message_id"],
"encoded_message_event_detail": sig["encoded_message_event_detail"],
"message_event_signature": {
"signature": sig["signature"],
"public_key_version": sig["public_key_version"],
"signature_version": sig["signature_version"],
},
}
for sig in prepared["action_signatures"]
],
},
)
conversation_id = resp.data["conversation_id"] # canonical id for later requests
sequence_id = resp.data["sequence_id"]
conv_key = prepared["conversation_key"]
conv_key_version = prepared["conversation_key_version"]
async function publicKeyInput(userId: string) {
const r = (await client.chat.getUserPublicKeys(userId, {
publicKeyFields: ['public_key_version', 'public_key'],
})).data[0];
return { userId, publicKey: r.public_key, keyVersion: r.public_key_version };
}
// Omit conversationId for a new 1:1; pass the id to rotate later
const prepared = chat.prepareConversationKeyChange({
publicKeys: [
await publicKeyInput('YOUR_USER_ID'),
await publicKeyInput('RECIPIENT_USER_ID'),
],
});
const resp = await client.chat.addConversationKeys('RECIPIENT_USER_ID', {
conversation_key_version: prepared.conversationKeyVersion,
conversation_participant_keys: prepared.participantKeys.map((pk) => ({
user_id: pk.userId,
encrypted_conversation_key: pk.encryptedKey,
public_key_version: pk.publicKeyVersion,
})),
action_signatures: prepared.actionSignatures.map((sig) => ({
message_id: sig.messageId,
encoded_message_event_detail: sig.encodedMessageEventDetail,
message_event_signature: {
signature: sig.signature,
public_key_version: sig.publicKeyVersion,
signature_version: sig.signatureVersion,
},
})),
});
const conversationId = resp.data.conversation_id; // canonical id for later requests
const sequenceId = resp.data.sequence_id;
const convKey = prepared.conversationKey;
const convKeyVersion = prepared.conversationKeyVersion;
// public_key_inputs: Vec<PublicKeyInput> from GET public keys
// (user_id, public_key, key_version ← public_key_version)
// New 1:1; set params.conversation_id = Some(id) to rotate later
let prepared = chat.prepare_conversation_key_change(
ConversationKeyChangeParams::new(public_key_inputs),
)?;
let participant_keys: Vec<_> = prepared
.participant_keys
.iter()
.map(|pk| {
serde_json::json!({
"user_id": pk.user_id,
"encrypted_conversation_key": pk.encrypted_key,
"public_key_version": pk.public_key_version,
})
})
.collect();
let action_signatures: Vec<_> = prepared
.action_signatures
.iter()
.map(|sig| {
serde_json::json!({
"message_id": sig.message_id,
"encoded_message_event_detail": sig.encoded_message_event_detail,
"message_event_signature": {
"signature": sig.signature,
"public_key_version": sig.public_key_version,
"signature_version": sig.signature_version,
},
})
})
.collect();
let body = serde_json::json!({
"conversation_key_version": prepared.conversation_key_version,
"conversation_participant_keys": participant_keys,
"action_signatures": action_signatures,
});
let resp: serde_json::Value = http
.post(format!("https://api.x.com/2/chat/conversations/{recipient_id}/keys"))
.header("Authorization", &auth)
.json(&body)
.send()?
.json()?;
// Canonical id for later requests
let conversation_id = resp["data"]["conversation_id"].as_str().unwrap().to_string();
// conversation_key is Option<XChatConversationKey>; encrypt_message wants owned bytes
let conv_key = prepared.conversation_key.expect("key present").to_bytes();
let conv_key_version = prepared.conversation_key_version;
// KeyVersion comes from the public_key_version field on each record
prepared, err := chat.PrepareConversationKeyChange(chatxdk.ConversationKeyChangeParams{
PublicKeys: []chatxdk.PublicKeyInput{
{UserID: myUserID, PublicKey: myIdentityPubB64, KeyVersion: myKeyVersion},
{UserID: recipientID, PublicKey: theirIdentityPubB64, KeyVersion: theirKeyVersion},
},
// ConversationID empty for a new 1:1; pass the id to rotate later
})
var parts []map[string]string
for _, pk := range prepared.ParticipantKeys {
parts = append(parts, map[string]string{
"user_id": pk.UserID,
"encrypted_conversation_key": pk.EncryptedKey,
"public_key_version": pk.PublicKeyVersion,
})
}
var sigs []map[string]any
for _, sig := range prepared.ActionSignatures {
sigs = append(sigs, map[string]any{
"message_id": sig.MessageID,
"encoded_message_event_detail": sig.EncodedMessageEventDetail,
"message_event_signature": map[string]string{
"signature": sig.Signature,
"public_key_version": sig.PublicKeyVersion,
"signature_version": sig.SignatureVersion,
},
})
}
body, _ := json.Marshal(map[string]any{
"conversation_key_version": prepared.ConversationKeyVersion,
"conversation_participant_keys": parts,
"action_signatures": sigs,
})
req, _ := http.NewRequest(http.MethodPost,
"https://api.x.com/2/chat/conversations/"+recipientID+"/keys",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
// Response data.conversation_id is the canonical id for later requests
_ = resp
convKey := prepared.ConversationKey
convKeyVersion := prepared.ConversationKeyVersion
// KeyVersion comes from the public_key_version field on each record
var prepared = chat.PrepareConversationKeyChange(new ConversationKeyChangeParams(new[] {
new PublicKeyInput { UserId = myUserId, PublicKey = myPk, KeyVersion = myVer },
new PublicKeyInput { UserId = recipientId, PublicKey = theirPk, KeyVersion = theirVer },
})); // ConversationId null for a new 1:1; set it to rotate later
var keysBody = new {
conversation_key_version = prepared.ConversationKeyVersion,
conversation_participant_keys = prepared.ParticipantKeys.Select(pk => new {
user_id = pk.UserId,
encrypted_conversation_key = pk.EncryptedKey,
public_key_version = pk.PublicKeyVersion,
}),
action_signatures = prepared.ActionSignatures.Select(sig => new {
message_id = sig.MessageId,
encoded_message_event_detail = sig.EncodedMessageEventDetail,
message_event_signature = new {
signature = sig.Signature,
public_key_version = sig.PublicKeyVersion,
signature_version = sig.SignatureVersion,
},
}),
};
var json = System.Text.Json.JsonSerializer.Serialize(keysBody);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var resp = await http.PostAsync(
$"https://api.x.com/2/chat/conversations/{Uri.EscapeDataString(recipientId)}/keys",
content);
resp.EnsureSuccessStatusCode();
var data = System.Text.Json.JsonDocument.Parse(await resp.Content.ReadAsStringAsync())
.RootElement.GetProperty("data");
string conversationId = data.GetProperty("conversation_id").GetString()!; // canonical id
byte[] convKey = prepared.ConversationKey!;
string convKeyVersion = prepared.ConversationKeyVersion;
// keyVersion comes from the public_key_version field on each record
PublicKeyInput mine = new PublicKeyInput();
mine.userId = myUserId; mine.publicKey = myIdentityPubB64; mine.keyVersion = myKeyVersion;
PublicKeyInput theirs = new PublicKeyInput();
theirs.userId = recipientId; theirs.publicKey = theirIdentityPubB64; theirs.keyVersion = theirKeyVersion;
// conversationId stays null for a new 1:1; set it to rotate later
PreparedConversationChange prepared =
chat.prepareConversationKeyChange(new ConversationKeyChangeParams(List.of(mine, theirs)));
List<Map<String, String>> parts = new ArrayList<>();
for (var pk : prepared.participantKeys) {
parts.add(Map.of(
"user_id", pk.userId,
"encrypted_conversation_key", pk.encryptedKey,
"public_key_version", pk.publicKeyVersion));
}
List<Map<String, Object>> sigs = new ArrayList<>();
for (var sig : prepared.actionSignatures) {
sigs.add(Map.of(
"message_id", sig.messageId,
"encoded_message_event_detail", sig.encodedMessageEventDetail,
"message_event_signature", Map.of(
"signature", sig.signature,
"public_key_version", sig.publicKeyVersion,
"signature_version", sig.signatureVersion)));
}
ObjectMapper mapper = new ObjectMapper();
String body = mapper.writeValueAsString(Map.of(
"conversation_key_version", prepared.conversationKeyVersion,
"conversation_participant_keys", parts,
"action_signatures", sigs));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.x.com/2/chat/conversations/" + recipientId + "/keys"))
.header("Authorization", "Bearer " + accessToken)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
JsonNode data = mapper.readTree(resp.body()).path("data");
String conversationId = data.path("conversation_id").asText(); // canonical id
byte[] convKey = prepared.conversationKey;
String convKeyVersion = prepared.conversationKeyVersion;
5. 메시지 보내기
4단계의 원시 대화 키로 암호화합니다. SDK는 메시지 ID(UUID)를 생성하여 서명된 이벤트에 포함시키고 페이로드에 반환합니다—절대 직접 만들지 마세요. 전송 요청에서는 다음을 매핑하세요:| Chat XDK 필드 | 요청 본문 필드 |
|---|---|
encrypted_content / encryptedContent / EncryptedContent | encoded_message_create_event |
encoded_event_signature / encodedEventSignature / EncodedEventSignature | encoded_message_event_signature |
페이로드 message_id / messageId / MessageId | message_id |
: → -). SDK 자체는 유연합니다: encrypt_message와 encrypt_reply는 당신이 보유한 어떤 형태의 ID든 받아들입니다—이벤트의 A:B, 목록이나 URL 경로의 A-B(순서 무관), 혹은 그저 수신자의 사용자 ID까지—그리고 서명 전에 정규화합니다. 그룹 ID(접두사 g)는 변경 없이 통과합니다.
- Python
- TypeScript
- Rust
- Go
- C#
- Java
from xdk.chat.models import SendMessageRequest
# Sender identity resolves from set_identity (step 2)
payload = chat.encrypt_message(
"CONVERSATION_ID",
"Hello!",
conversation_key=conv_key,
conversation_key_version=conv_key_version,
)
client.chat.send_message(
"RECIPIENT_USER_ID",
SendMessageRequest(
message_id=payload.message_id, # SDK-generated, embedded in the signed event
encoded_message_create_event=payload.encrypted_content,
encoded_message_event_signature=payload.encoded_event_signature,
),
)
// Sender identity resolves from setIdentity (step 2)
const payload = chat.encryptMessage({
conversationId: 'CONVERSATION_ID',
text: 'Hello!',
conversationKey: convKey,
conversationKeyVersion: convKeyVersion,
});
await client.chat.sendMessage('RECIPIENT_USER_ID', {
message_id: payload.messageId, // SDK-generated, embedded in the signed event
encoded_message_create_event: payload.encryptedContent,
encoded_message_event_signature: payload.encodedEventSignature,
});
use chat_xdk_core::EncryptMessageParams;
// Sender identity resolves from set_identity (step 2)
let payload = chat.encrypt_message(
EncryptMessageParams::new(&conversation_id, "Hello!")
.with_conversation_key(conv_key, &conv_key_version),
)?;
let body = serde_json::json!({
// SDK-generated, embedded in the signed event
"message_id": payload.message_id,
"encoded_message_create_event": payload.encrypted_content,
"encoded_message_event_signature": payload.encoded_event_signature,
});
let path_id = conversation_id.replace(':', "-");
http.post(format!("https://api.x.com/2/chat/conversations/{path_id}/messages"))
.header("Authorization", &auth)
.json(&body)
.send()?;
// Sender identity resolves from SetIdentity (step 2)
payload, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{
ConversationID: conversationID,
Text: "Hello!",
ConversationKey: convKey,
ConversationKeyVersion: convKeyVersion,
})
if err != nil {
log.Fatal(err)
}
body, _ := json.Marshal(map[string]string{
// SDK-generated, embedded in the signed event
"message_id": payload.MessageID,
"encoded_message_create_event": payload.EncryptedContent,
"encoded_message_event_signature": payload.EncodedEventSignature,
})
pathID := strings.ReplaceAll(conversationID, ":", "-")
req, _ := http.NewRequest(http.MethodPost,
"https://api.x.com/2/chat/conversations/"+pathID+"/messages",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
_ = resp
// Sender identity resolves from SetIdentity (step 2)
var payload = chat.EncryptMessage(new EncryptMessageParams(conversationId, "Hello!") {
ConversationKey = convKey,
ConversationKeyVersion = convKeyVersion,
});
var sendJson = System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, string> {
// SDK-generated, embedded in the signed event
["message_id"] = payload.MessageId,
["encoded_message_create_event"] = payload.EncryptedContent,
["encoded_message_event_signature"] = payload.EncodedEventSignature,
});
using var content = new StringContent(sendJson, Encoding.UTF8, "application/json");
var pathId = conversationId.Replace(':', '-');
using var resp = await http.PostAsync(
$"https://api.x.com/2/chat/conversations/{Uri.EscapeDataString(pathId)}/messages",
content);
resp.EnsureSuccessStatusCode();
// Sender identity resolves from setIdentity (step 2)
EncryptMessageParams params = new EncryptMessageParams(conversationId, "Hello!");
params.conversationKey = convKey;
params.conversationKeyVersion = convKeyVersion;
SendPayload payload = chat.encryptMessage(params);
String pathId = conversationId.replace(':', '-');
String sendJson = new ObjectMapper().writeValueAsString(Map.of(
// SDK-generated, embedded in the signed event
"message_id", payload.messageId,
"encoded_message_create_event", payload.encryptedContent,
"encoded_message_event_signature", payload.encodedEventSignature));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.x.com/2/chat/conversations/" + pathId + "/messages"))
.header("Authorization", "Bearer " + accessToken)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(sendJson))
.build();
http.send(req, HttpResponse.BodyHandlers.ofString());
스니펫은 이 흐름에서 방금 4단계에서 대화 키를 생성했기 때문에 명시적으로 전달합니다. 키 캐시가 켜져 있고
decrypt_events 실행이 대화의 키를 검증한 후에는(6단계), encrypt_message(conversation_id, text)만으로 충분합니다—SDK가 최신 검증된 키를 채웁니다. 재시도는 같은 암호화된 페이로드를 다시 보내야 하므로 ID가 두 번 생성되지 않습니다.6. 수신 및 복호화
실시간 트래픽에는 웹훅 또는 활동 스트림을 사용하고, 히스토리에는 대화 events를 페이지 조회하세요.- 실시간 페이로드 필드:
encoded_event, 선택적conversation_key_change_event - 히스토리:
GET /2/chat/conversations/{id}/events— 모든 이벤트에 **decrypt_events**와meta.conversation_key_events를 함께 사용하는 것을 권장 - 복호화하려면 발신자의 서명 키가 필요하므로 SDK가 각 메시지의 작성자를 검증할 수 있습니다. 이는 다른 참여자의 공개 키입니다—4단계에서 사용한 동일한 공개 키 엔드포인트에서 가져와
SigningKeyEntry로 필드를 매핑하세요(아래 스니펫에 매핑이 포함되어 있습니다). - 모든 호출에 서명 키를(그리고
decrypt_event의 경우 대화 키를) 전달하거나, 두 개의 선택적 세션 저장소를 한 번 설정한 후 짧은 호출 형태를 사용할 수 있습니다. 아래 스니펫은 저장소를 사용합니다:set_signing_keys(entries)는 참여자의 키를 보관하고,set_cache_keys(true)(기본은 꺼짐)는 각 대화의 최신 서명 검증된 키를 보관하여 이후 호출이 키 인자를 생략할 수 있게 합니다. 두 스타일 모두 동일하게 검증합니다. - JavaScript는 카멜케이스 이벤트 타입(
message)을 사용하고, 다른 언어는 JSON에서"Message"와 스네이크케이스 필드를 사용합니다.
- Python
- TypeScript
- Rust
- Go
- C#
- Java
# Once per process: fill the signing-key store and enable the key cache
def signing_keys_for(user_id: str) -> list[dict]:
resp = client.chat.get_user_public_keys(
user_id,
public_key_fields=[
"public_key_version", "public_key", "signing_public_key", "identity_public_key_signature",
],
)
return [
{
"user_id": user_id,
"public_key_version": r["public_key_version"],
"public_key": r["signing_public_key"],
"identity_public_key": r["public_key"],
"identity_public_key_signature": r["identity_public_key_signature"],
}
for r in resp.data
]
chat.set_signing_keys(
signing_keys_for("YOUR_USER_ID") + signing_keys_for("RECIPIENT_USER_ID")
)
chat.set_cache_keys(True)
# Initial load or pagination: batch decrypt. Conversation keys are
# extracted from the KeyChange events in the batch; per-event failures
# are collected in result["errors"], never raised.
result = chat.decrypt_events(all_events_b64)
for dm in result["messages"]:
event = dm["event"]
if event["type"] == "Message" and event["content"]["content_type"] == "Text":
print(event["sender_id"], event["content"]["text"], event["verified"])
# Live traffic: one event at a time
def handle_payload(payload: dict):
if payload.get("conversation_key_change_event"):
# A rotation enters the key cache only after its signature
# verifies, which is what decrypt_events does
chat.decrypt_events([payload["conversation_key_change_event"]])
event = chat.decrypt_event(payload["encoded_event"]) # raises on failure
if event["type"] == "Message" and event["content"]["content_type"] == "Text":
print(event["sender_id"], event["content"]["text"], event["verified"])
// Once per process: fill the signing-key store and enable the key cache
async function signingKeysFor(userId: string) {
const resp = await client.chat.getUserPublicKeys(userId, {
publicKeyFields: [
'public_key_version', 'public_key', 'signing_public_key', 'identity_public_key_signature',
],
});
return resp.data.map((r: {
public_key_version: string;
public_key: string;
signing_public_key: string;
identity_public_key_signature: string;
}) => ({
userId,
publicKeyVersion: r.public_key_version,
publicKey: r.signing_public_key,
identityPublicKey: r.public_key,
identityPublicKeySignature: r.identity_public_key_signature,
}));
}
chat.setSigningKeys([
...(await signingKeysFor('YOUR_USER_ID')),
...(await signingKeysFor('RECIPIENT_USER_ID')),
]);
chat.setCacheKeys(true);
// Initial load or pagination: batch decrypt. Conversation keys are
// extracted from the KeyChange events in the batch; per-event failures
// are collected in result.errors, never thrown.
const result = chat.decryptEvents(allEventsB64);
for (const dm of result.messages) {
if (dm.event.type === 'message' && dm.event.content?.contentType === 'text') {
console.log(dm.event.senderId, dm.event.content.text, dm.event.verified);
}
}
// Live traffic: one event at a time
function handlePayload(payload: {
encoded_event: string;
conversation_key_change_event?: string;
}) {
if (payload.conversation_key_change_event) {
// A rotation enters the key cache only after its signature
// verifies, which is what decryptEvents does
chat.decryptEvents([payload.conversation_key_change_event]);
}
const event = chat.decryptEvent(payload.encoded_event); // throws on failure
if (event.type === 'message' && event.content?.contentType === 'text') {
console.log(event.senderId, event.content.text, event.verified);
}
}
// Once per instance: fill the signing-key store (Vec<SigningKeyEntry>
// from GET /2/users/{id}/public_keys) and enable the key cache
chat.set_signing_keys(participant_signing_keys);
chat.set_cache_keys(true);
// Initial load: batch decrypt — per-event failures land in result.errors
let result = chat.decrypt_events(&all_events_b64, &[]);
// Live traffic: a rotation enters the key cache only after its
// signature verifies, which is what decrypt_events does
if let Some(kc) = key_change_b64.as_deref() {
chat.decrypt_events(&[kc], &[]);
}
let event = chat.decrypt_event(&encoded_event, &Default::default(), &[])?;
// Once per instance: fill the signing-key store ([]SigningKeyEntry
// from GET /2/users/{id}/public_keys) and enable the key cache
if err := chat.SetSigningKeys(participantSigningKeys); err != nil {
log.Fatal(err)
}
chat.SetCacheKeys(true)
// Initial load: batch decrypt — per-event failures land in result.Errors
result, err := chat.DecryptEvents(allEventsB64, nil)
if err != nil {
log.Fatal(err)
}
for _, dm := range result.Messages {
if dm.Event.Type == "Message" {
fmt.Println(dm.Event.AsMessage().Text())
}
}
// Live traffic: a rotation enters the key cache only after its
// signature verifies, which is what DecryptEvents does
if keyChange != "" {
chat.DecryptEvents([]string{keyChange}, nil)
}
event, err := chat.DecryptEvent(encodedEvent, nil, nil)
if err == nil && event.Type == "Message" {
fmt.Println(event.AsMessage().Text())
}
// Once per instance: fill the signing-key store (SigningKeyEntry list
// from GET /2/users/{id}/public_keys) and enable the key cache
chat.SetSigningKeys(participantSigningKeys);
chat.SetCacheKeys(true);
// Initial load: batch decrypt — per-event failures land in result.Errors
var result = chat.DecryptEvents(allEventsB64);
foreach (var dm in result.Messages)
{
if (dm.Event.GetProperty("type").GetString() == "Message")
Console.WriteLine(dm.Event.GetProperty("content").GetProperty("text").GetString());
}
// Live traffic: a rotation enters the key cache only after its
// signature verifies, which is what DecryptEvents does
if (!string.IsNullOrEmpty(keyChangeB64))
chat.DecryptEvents(new[] { keyChangeB64 });
var evt = chat.DecryptEvent(encodedEvent); // throws on failure
if (evt.GetProperty("type").GetString() == "Message")
Console.WriteLine(evt.GetProperty("content").GetProperty("text").GetString());
// Once per instance: fill the signing-key store (SigningKeyEntry list
// from GET /2/users/{id}/public_keys) and enable the key cache
chat.setSigningKeys(participantSigningKeys);
chat.setCacheKeys(true);
// Initial load: batch decrypt — per-event failures land in result.errors
DecryptEventsResult result = chat.decryptEvents(allEventsB64, null);
for (DecryptedMessage dm : result.messages) {
if ("Message".equals(dm.event.path("type").asText())) {
System.out.println(dm.event.path("content").path("text").asText());
}
}
// Live traffic: a rotation enters the key cache only after its
// signature verifies, which is what decryptEvents does
if (keyChangeB64 != null && !keyChangeB64.isEmpty()) {
chat.decryptEvents(List.of(keyChangeB64), null);
}
JsonNode evt = chat.decryptEvent(encodedEvent, (Map<String, byte[]>) null, null);
if ("Message".equals(evt.path("type").asText())) {
System.out.println(evt.path("content").path("text").asText());
}
서버리스나 멀티 인스턴스인가요? 서명 키 저장소와 키 캐시는 SDK 인스턴스의 메모리에 있습니다. 이 방식이 맞지 않는 경우—한 호출이 복호화하고 다른 호출이 전송하는—키를 명시적으로 전달하세요:
decrypt_events(events, signing_keys), decrypt_event(event_b64, conversation_keys, signing_keys), 그리고 encrypt 메서드의 conversation_key/conversation_key_version 오버라이드를 사용하세요. decrypt_events가 반환하는 conversation_keys는 직접 저장하여 다시 전달하세요.모범 사례
- 서명 키 저장소를 최신 상태로 유지하세요: 발신자가 새 키 버전을 등록할 때 전체 참여자 세트로
set_signing_keys를 다시 호출하고, 서명 검증 실패 시 새로 고치세요 - 실시간 전달은
event_uuid로 중복 제거하세요