# Mocco Messenger로 앱 안에서 문의받기

Messenger를 쓰면 앱에 로그인한 사용자가 앱을 나가지 않고 팀에 글을 보낼 수 있고, 팀은 Mocco의 한 인박스에서 답변할 수 있습니다. 문의 하나가 대화 하나입니다. 사용자는 카테고리를 고르고 글을 쓰며, 같은 스레드에서 계속 답장할 수 있습니다. 앱 버전, 빌드, 플랫폼이 함께 전달되므로 사용자가 무엇을 실행 중이었는지 알 수 있습니다.

## 1. Messenger 켜기

워크스페이스 멤버가 워크스페이스의 **Products** 페이지에서 **Messenger**를 켭니다. 그러면 각 프로젝트에 **Inbox** 페이지가 생깁니다.

## 2. Messenger 설정하기

프로젝트의 **Inbox** 페이지에서 **Set up messenger**를 선택합니다. Mocco가 프로젝트의 **identity secret**을 만들어 한 번만 보여 줍니다. 복사해서 서버의 시크릿으로 저장하세요(예: `MOCCO_MESSENGER_SECRET`).

![Messenger 설정: 한 번만 표시되는 identity secret과 서버에서 사용자 ID를 서명하는 방법](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/15aa49ce-27a7-4fbf-b50a-900563dc07c3/messenger-setup.png)

이 시크릿으로 Mocco는 요청이 실제로 내 사용자에게서 왔는지 확인합니다. 서버가 로그인한 사용자의 ID를 시크릿으로 서명하고, 앱은 ID와 함께 그 서명을 보냅니다. 앱만 가진 사람은 서명을 만들 수 없으므로 다른 사용자인 척 글을 쓸 수 없습니다.

시크릿이 유출되면 인박스 설정에서 **Rotate secret**을 선택하세요. 이전 시크릿으로 만든 서명은 즉시 동작하지 않으므로, 새 시크릿을 서버에 바로 적용해야 합니다.

## 3. 앱용 키 만들기

**API keys** 페이지에서 **messenger:chat** 스코프의 **Publishable** 키를 만듭니다. 이 키는 앱에 넣어 배포해도 안전합니다. 서버의 서명이 없으면 키만으로는 아무것도 열 수 없습니다.

## 4. 서버에서 사용자 ID 서명하기

앱에 로그인한 사용자의 서명을 앱에 전달하세요. 예를 들어 앱이 로그인 후 이미 호출하는 엔드포인트에서 전달할 수 있습니다.

```ts
import { signIdentity } from '@mocco/node';

// For the signed-in user only.
const userHash = signIdentity(process.env.MOCCO_MESSENGER_SECRET!, user.id);
```

`signIdentity`는 사용자 ID의 HMAC-SHA256을 16진수 문자열로 만든 값입니다. 어떤 언어로도 같은 값을 계산할 수 있습니다.

## 5. 앱에서 팀에 문의하기

### React Native

SDK를 설치합니다. 순수 JavaScript이므로 Expo Go에서도 실행됩니다.

```bash
npm install @mocco/react-native @react-native-async-storage/async-storage
```

앱에서 Messenger 클라이언트를 하나 만들고 앱을 프로바이더로 감쌉니다. `identity`는 서버에 로그인한 사용자의 서명을 요청하고, 로그인한 사람이 없으면 `null`을 반환합니다.

```tsx
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createMessenger, MessengerProvider } from '@mocco/react-native/messenger';
import * as Application from 'expo-application';
import { Platform } from 'react-native';

const messenger = createMessenger({
  publishableKey: 'mk_pub_…',
  identity: async () => {
    const user = await getSignedInUser();
    if (user === null) return null;
    const { userHash } = await api.messengerIdentity(); // your endpoint, signs user.id
    return { userId: user.id, userHash, name: user.name, email: user.email };
  },
  context: () => ({
    appVersion: Application.nativeApplicationVersion ?? undefined,
    build: Application.nativeBuildVersion ?? undefined,
    platform: Platform.OS === 'ios' ? 'ios' : 'android',
  }),
  storage: AsyncStorage,
});

export default function App() {
  return (
    <MessengerProvider client={messenger}>
      <Navigation />
    </MessengerProvider>
  );
}
```

그다음 훅으로 화면을 원하는 디자인대로 그립니다.

```tsx
import { useConversation, useConversations, useMessengerCategories, useUnreadCount } from '@mocco/react-native/messenger';

function SettingsRow() {
  const unread = useUnreadCount(); // a badge on "Contact us"
  // …
}

function ContactList() {
  const { conversations, start } = useConversations(); // refreshed while the screen is open
  const categories = useMessengerCategories();
  // start({ category: 'bug', body }) to open a new one
}

function Thread({ id }: { id: string }) {
  const { messages, send, isSending } = useConversation(id); // new messages are marked read
  // messages: { seq, author: 'contact' | 'operator', authorName, body, createdAt }
}
```

사용자가 앱에서 계정을 삭제하면 `useMessenger().deleteMyData()`를 호출하세요. Mocco에 있는 대화, 메시지, 스크린샷을 지우고 기기에서도 사용자 정보를 잊습니다. 누군가 앱에 로그인하면 `useMessenger().reidentify()`를 호출해 Messenger가 그 사람의 세션을 열게 하세요. 사용자가 앱에서 로그아웃하면 `useMessenger().signOut()`을 호출해 다음 사용자가 새로 시작하게 하세요.

스크린샷(PNG, JPEG, WebP, GIF)이나 PDF를 첨부하려면 먼저 업로드하고 그 ID를 보냅니다. 파일은 각각 10 MB 이하, 메시지당 최대 3개입니다.

```tsx
const messenger = useMessenger();
const picked = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'] });
const asset = picked.assets?.[0];
if (asset !== undefined) {
  // Send bytes: with a Blob, React Native replaces the upload's Content-Type and it is refused.
  const body = new Uint8Array(await (await fetch(asset.uri)).arrayBuffer());
  const attachmentId = await messenger.attach({
    body,
    contentType: 'image/jpeg', // the picked image's real type
    sizeBytes: body.byteLength,
  });
  await send('Here is what I see', [attachmentId]); // from useConversation(id)
}
```

PDF도 같은 방식이며 `contentType: 'application/pdf'`를 사용합니다(예: 문서 선택기에서 고른 파일). 파일의 실제 타입을 선언하세요. Mocco는 파일의 앞부분을 읽으며, 파일이 선언한 타입과 다르면 메시지를 거부하고 파일을 삭제합니다.

메시지는 `attachments`와 함께 돌아오며, 각 첨부에는 `filename`, `sizeBytes`, 몇 분 동안만 유효한 `url`이 있습니다. 이미지는 `<Image source={{ uri: attachment.url }} />`로 표시하세요. PDF의 `url`은 다운로드이므로, 화면에 바로 띄우지 말고 파일(이름과 크기)로 보여 주고 `Linking.openURL(attachment.url)`로 열거나 저장하게 하세요.

앱이 닫혀 있을 때 팀의 답변을 알림으로 받으려면 로그인 후 기기의 Expo 푸시 토큰을 등록하고, 사용자가 알림을 누르면 해당 대화를 여세요.

```tsx
import * as Notifications from 'expo-notifications';
import { messengerConversationIdOf, useMessenger } from '@mocco/react-native/messenger';

const messenger = useMessenger();
const { data: token } = await Notifications.getExpoPushTokenAsync();
await messenger.registerPushToken({ provider: 'expo', token, platform: Platform.OS === 'ios' ? 'ios' : 'android' });

Notifications.addNotificationResponseReceivedListener(response => {
  const conversationId = messengerConversationIdOf(response.notification.request.content.data);
  if (conversationId !== undefined) navigation.navigate('ContactThread', { id: conversationId });
});
```

알림의 제목은 프로젝트 이름이고 본문은 답변의 첫 부분입니다. 사용자가 앱에서 이미 읽은 답변은 푸시하지 않습니다. `signOut()`은 기기를 제거하므로, 그 기기에 다음으로 로그인한 사람이 이전 사용자의 답변을 받지 않습니다.

### 다른 클라이언트

SDK는 어디서든 쓸 수 있는 작은 HTTP API를 호출합니다. 기본 URL은 `https://api.mocco.club/v1/messenger`입니다.

```http
POST /sessions
Authorization: Bearer mk_pub_…
Content-Type: application/json

{
  "userId": "user-42",
  "userHash": "4b8d8248…",
  "name": "Minji",
  "email": "minji@example.com",
  "traits": { "plan": "premium" },
  "context": { "appVersion": "3.8.0", "build": "412", "platform": "ios", "locale": "ko-KR" }
}
```

응답에는 `sessionToken`(`mms_…`, 30일간 유효)과 프로젝트의 `categories`가 있습니다. 다른 호출에는 토큰을 `Authorization: Bearer mms_…`로 보냅니다.

| 호출 | 동작 |
|---|---|
| `GET /conversations` | 사용자의 대화를 최신순으로 반환합니다. 마지막으로 읽은 뒤 팀이 답변했으면 `hasUnread`가 true입니다 |
| `POST /conversations` | 대화를 시작합니다: `{ "category": "bug", "body": "…", "clientMessageId": "<uuid>", "context": {…} }` |
| `GET /conversations/{id}/messages?afterSeq=0` | 메시지를 오래된 순으로 반환합니다. 각 메시지에 `seq`가 있으며, 가지고 있는 마지막 값을 `afterSeq`로 보내면 새 메시지만 받습니다 |
| `POST /conversations/{id}/messages` | 답장합니다: `{ "body": "…", "clientMessageId": "<uuid>" }` |
| `POST /conversations/{id}/read` | 사용자가 해당 메시지까지 확인하면 `{ "seq": 5 }`를 보냅니다 |
| `POST /push-tokens` | 답변을 기기로 푸시하도록 `{ "provider": "expo", "token": "ExponentPushToken[…]", "platform": "ios" }`를 보냅니다. 로그아웃할 때는 `DELETE /push-tokens` `{ "token": … }`를 보냅니다 |
| `DELETE /me` | 사용자와 사용자가 쓴 모든 것을 삭제합니다. 앱의 "계정 삭제"에 사용합니다 |
| `POST /attachments` | `{ "contentType": "image/png", "sizeBytes": 48213 }`(또는 `application/pdf`)를 보내면 `attachmentId`와 바이트를 `PUT`할 `upload` URL을 반환합니다. 이후 대화를 시작하거나 답장할 때 그 ID를 `attachmentIds`에 넣습니다 |

메시지마다 새 `clientMessageId`를 만들고, 재시도할 때는 같은 값을 다시 사용하세요. 요청이 몇 번 도착해도 Mocco는 메시지를 한 번만 저장합니다. 사용자는 분당 메시지 20개를 보내고 시간당 대화 5개를 시작할 수 있습니다.

### 로그인하지 않은 사람

로그인하지 않은 사람을 포함해 누구나 문의할 수 있게 하려면, 인박스 설정에서 **Let people who aren't signed in write**를 켭니다. 이 사람들은 연락받을 이메일을 남깁니다. 앱에서 Messenger 상태가 `signed_out`이면 이메일을 입력받아 `continueAsGuest`를 호출하세요.

```tsx
const { status } = useMessengerState();
const messenger = useMessenger();

if (status === 'signed_out') {
  // show an email field, then:
  await messenger.continueAsGuest({ email, name });
}
```

기기가 게스트를 기억하므로, 다음에 앱을 열 때 답변을 볼 수 있습니다. 같은 기기에서 나중에 로그인하고 앱이 `reidentify()`를 호출하면, 게스트일 때 쓴 내용이 그 계정으로 옮겨집니다. 인박스에서 게스트의 대화에는 **Guest** 표시가 붙고, 그 옆에 **Not signed in**과 이메일이 표시됩니다. Mocco는 아직 대신 이메일을 보내지 않습니다. 앱에서 답장하거나, 남긴 이메일로 직접 연락하세요.

![게스트의 대화: "Not signed in" 배지와 남긴 이메일](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/a4624a9d-6b89-4f98-b9f4-93388bbcc3d9/messenger-guest.png)

## 6. 인박스에서 답변하기

**Inbox** 페이지에는 열린 대화가 최근 활동순으로 나열되며, 읽지 않은 대화에는 표시가 있습니다. 나머지는 **Closed**에 있습니다. 인박스를 열어 두면 몇 초마다 새 메시지를 확인합니다.

![인박스: Minji의 버그 신고 대화 하나가 열려 있음](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/60e05704-a198-4803-99cb-603c1ff5a256/messenger-inbox.png)

대화를 열면 스레드를 읽고 답변할 수 있습니다.

- **Reply**는 사용자가 앱에서 보는 메시지를 보냅니다.
- **Internal note**는 팀 전용입니다. 사용자에게는 보이지 않습니다.
- **Attach**는 답장이나 메모에 이미지(PNG, JPEG, WebP, GIF) 또는 PDF를 최대 3개, 각 10 MB까지 추가합니다.
- 해결되면 **Close**를 선택합니다. 사용자가 다시 글을 쓰면 저절로 다시 열립니다.
- 사이드 패널에는 사용자가 누구인지(앱이 설명한 대로), 지금 사용 중인 앱 버전과 플랫폼, 대화를 시작할 때 실행 중이던 환경이 표시됩니다.
- **Block user**는 해당 사용자가 글을 쓰지 못하게 합니다. 이미 가진 내용은 계속 읽을 수 있습니다.

![대화: 사용자의 메시지, 내부 메모, 팀의 답변, 사용자 패널](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/c0acbe87-f925-4c01-9ea1-11c59ec69c7d/messenger-conversation.png)

사용자가 첨부한 스크린샷은 스레드에 표시되며, 선택하면 원본 크기로 열립니다.

![대화 화면에서 스크린샷이 첨부된 메시지](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/e2563e82-de59-4379-9e7c-95c1cbeeccec/messenger-attachment.png)

PDF는 이름과 크기가 있는 파일로 표시됩니다. 선택하면 내려받습니다. PDF에는 스크립트가 들어 있을 수 있으므로 Mocco는 사용자의 PDF를 브라우저에서 열지 않습니다.

![PDF가 첨부된 메시지: 내려받을 수 있는 파일 이름과 크기](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/9c344897-1227-4237-9935-7caa8f965ac0/messenger-attachment-pdf.png)

사용자에게 파일을 보내려면 답장 입력란 아래의 **Attach**를 선택하고 이미지나 PDF를 고른 뒤 **Send reply**를 선택하세요. 각 파일이 텍스트 아래에 크기와 함께 표시됩니다. 보내기 전에 빼려면 **×**를 선택합니다.

![답장에 스크린샷과 PDF 영수증 첨부하기](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/caadfc29-46e1-4340-a264-5bdf0ce64894/messenger-reply-attach-composer.png)

보낸 답장은 스레드에 파일과 함께 표시되고, 사용자의 앱은 사용자가 보낸 파일을 받는 것과 같은 방식으로 메시지와 함께 파일을 받습니다. 이미지는 표시하고 PDF는 내려받게 합니다. 내부 메모에 첨부한 파일은 팀에만 보입니다.

![스크린샷과 PDF가 첨부되어 전송된 답장](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/8b1c26be-6a2d-4257-89b5-a7339b4cb6e8/messenger-reply-attachments.png)

Mocco는 사용자의 파일을 검사하는 것과 같은 방식으로 내 파일도 검사합니다. 실제로는 선언한 이미지나 PDF가 아닌 파일은 거부되고 아무것도 전송되지 않습니다.

누군가 데이터 삭제를 요청하면 사이드 패널에서 **Erase user's data**를 선택하고 확인하세요. Mocco가 사용자와 모든 대화, 메시지, 첨부를 삭제합니다. 되돌릴 수 없으며, 감사 로그에는 삭제가 일어났다는 사실만 기록됩니다.

![사용자 데이터 삭제: 사이드 패널의 확인 창](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/209c930e-ba81-4e21-b141-aa56634eff38/messenger-erase.png)

사용자가 고르는 카테고리는 인박스 설정에 있습니다. 앱은 세션마다 카테고리를 받으며, 레이블을 그대로 보여 주거나 키로 번역해서 보여 줄 수 있습니다.

### 라운드 로빈으로 업무 나누기

Mocco는 새 대화를 팀원 한 명에게 차례로 배정해, 일이 고르게 나뉘고 각자 맡은 대화를 알 수 있게 합니다. 인박스의 각 대화에는 담당자 또는 **Unassigned**가 표시됩니다.

- 인박스 상단의 **Join the rotation**을 선택하면 새 대화를 받기 시작합니다. 다른 사람을 추가하려면 인박스 설정의 **Round robin**에서 그 사람을 고르세요. 워크스페이스의 누구든 추가할 수 있습니다.
- 인박스 상단의 스위치는 내 근무 가능 여부입니다. 자리를 비운 동안에는 새 대화가 나를 건너뛰고 다음으로 가능한 사람에게 갑니다. 돌아오면 다음 대화가 내 것이 됩니다.
- 가능한 사람이 없으면 누군가 맡을 때까지 새 대화는 미배정 상태로 남습니다.
- 대화를 다른 사람에게 넘기거나 미배정 대화를 가져오려면 대화 상단의 **Assigned to**에서 사람을 고르세요. 로테이션에 있든 없든 워크스페이스의 누구나 맡을 수 있습니다. 다시 돌려놓으려면 **No one**을 선택합니다. 모든 변경은 감사 로그에 남습니다.
- 로테이션에서 누군가를 빼도 그 사람이 이미 가진 대화는 그대로입니다.

![라운드 로빈: 인박스 위의 근무 가능 스위치, 각 대화의 담당자, 설정의 로테이션](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/efc4bd0b-9232-4859-80bd-1d20e11e1041/messenger-round-robin.png)

![Assigned to 선택기에 팀원이 지정된 대화 헤더](https://fi-workers-mocco-storage.s3.ap-northeast-2.amazonaws.com/pub/w/93e11fe8-0494-4e3d-b1cb-fe4dffd3ee7e/p/d64785b8-9a23-4fa1-a81d-550af30e203a/helpcenter/83a49006-c502-45d0-a4fb-c81083cb3929/messenger-assign.png)

## 7. 알림 받기

새 대화(`messenger.conversation.created`)와 사용자의 추가 메시지(`messenger.message.received`)는 Mocco 이벤트입니다. Discord 채널의 규칙에 **Mocco** 프리셋을 적용하거나 이 두 이벤트에 대한 규칙을 추가하면, 팀이 이미 쓰는 곳에서 알림을 받을 수 있습니다([Mocco 이벤트](https://www.mocco.club/docs/ko/articles/eriac3-mocco-events#the-mocco-preset) 참고). 새 대화 알림에는 누구에게 배정되었는지가 표시됩니다.

아무도 맡지 않은 대화만 알림받고 싶다면 대신 `messenger.conversation.unassigned` 규칙을 추가하세요. 로테이션에 가능한 사람이 없을 때 대화가 시작되면 Mocco가 이 이벤트를 보냅니다. 대화 이벤트는 앱이 재시도해도 채널에 한 번만 전달됩니다.

인박스를 브라우저 탭에 열어 두면 Mocco가 새 대화마다 데스크톱 알림을 보여 줄 수도 있습니다. 인박스 상단의 **Turn on desktop notifications**를 선택하고 브라우저에서 허용하세요. 알림을 누르면 해당 대화가 열립니다. 탭을 닫으면 알림도 멈춥니다.
