안녕하세요! 😁
채널 앱 개발 튜토리얼에 오신 것을 환영합니다.
이 문서를 통해 이런 작업을 할 수 있어요.
- 앱스토어(App Store)에 대한 간단한 이해
- 앱 개발
- 앱 설치
- 앱 사용하기
이 문서는 앱스토어 및 하위 개념들에 대해 개괄적인 내용만 설명하고 있습니다. 자세한 내용은 각 항목에 대한 개발자 페이지에서 확인해 주세요.
(→ 인증 및 권한)
(→ Function)
(→ Command)
(→ WAM)
app-tutorial(typescript , go)에서 이 문서에 삽입된 코드를 한 번에 확인할 수 있습니다.
앱스토어(App Store)란 무엇인가
이제 채널톡 개발자가 아니라도 우리 채널에 필요한 맞춤형 서드 파티 앱을 개발하고 채널에 등록할 수 있습니다!
- 개발자들은 채널톡 규격에 맞는 특별한 앱을 개발하고 앱스토어 서버에 등록합니다.
- 채널 매니저는 앱스토어 플랫폼에서 등록된 앱 목록을 확인하고 자신의 채널에 등록할 수 있습니다.
앱을 비공개(Private)로 등록해 개발자 본인 소유자로 속한 채널에서만 사용하거나 전체 공개(Public)해 채널 앱스토어 생태계에 기여하는 것도 가능합니다.
앱스토어에 대한 자세한 내용은 다른 페이지에서 확인 가능합니다.
(→ 앱스토어 사용자 가이드)
이미 준비된 앱 서버가 있다면 개발자 포털에서 바로 앱을 등록해 보세요.
(→ 개발자 포털)
필요한 도구
채널톡에서는 다른 서드 파티 당사자가 앱스토어를 쉽게 이해하고 앱을 개발할 수 있도록 샘플 앱인 app-tutorial(typescript , go )을 제공해요.
이 앱은 다음 도구를 사용하여 개발되었습니다.
typescript
go
아직 위 도구를 설치하지 않은 경우 해당 페이지를 참고해 미리 준비해 주세요.
샘플 앱은 typescript와 go 언어로 개발되었으나 앱스토어와 앱 서버 간 인터페이스 규격을 준수하는 경우 다른 언어를 사용해도 무방합니다.
app-tutorial(typescript , go)은 다음 두 가지 동작을 수행할 수 있도록 구현되었습니다.
- 매니저가 WAM에서 봇으로 그룹 메시지 전송하기 버튼을 클릭할 경우, 앱 서버에서 봇 프로필을 사용해 그룹 메시지를 전송해 줍니다.
- 매니저가 WAM에서 매니저로서 그룹 메시지 전송하기 버튼을 클릭할 경우, WAM에서 매니저 프로필을 사용해 그룹 메시지를 전송해 줍니다.
개발 과정에 대한 설명 없이 바로 샘플 앱을 테스트 해 보고 싶은 경우 이 문서 마지막의 테스트 파트를 참고해 주세요.
앱 등록
앱 서버를 개발하기 전 먼저 앱을 등록해야 해요. 개발자 포털로 이동해 앱 등록을 시작해 봅시다!
채널톡은 개발자도 앱스토어 서버에 쉽게 앱을 등록할 수 있는 개발자 포털을 제공해요!
(→ 개발자 포털)
1. 앱 이름 설정
앱 등록 페이지의 오른쪽 상단에서 앱 만들기 버튼을 확인할 수 있습니다.
앱 이름을 설정하고 확인을 클릭해 주세요.
확인을 클릭하는 순간 앱이 생성됩니다!
하지만 걱정하지 마세요. 앱 이름은 나중에 수정이 가능합니다. :)
2. 앱 기본 정보 입력
1에서 확인을 클릭하면 기본 정보 입력 화면으로 넘어옵니다.
각 항목에 필요한 정보를 입력해주세요.
Type | Description |
---|---|
앱 아이콘 | 앱스토어 및 Command 사용 시 노출 될 아이콘 업로드 |
앱 이름 | 앱스토어에 노출 될 앱 이름 |
앱 소개 | 앱 설명 |
상세 설명 | 앱에 대한 자세한 설명 및 사용 방법 등 |
스크린샷 | 앱의 기능을 표현하는 이미지 (최대 5장) |
사용 가이드 | 앱 설치 및 사용 방법이 있는 외부 가이드 문서 등 |
3. App ID 확인
기본 정보 페이지를 잘 보면 Application ID라는 항목을 볼 수 있습니다.
이 값은 클라이언트, 앱스토어, 앱 서버 간 요청 및 응답 시 앱을 식별하는데 사용됩니다. 앱 개발 시 앱 서버의 환경 설정에 반드시 필요한 값이므로 App ID를 확인할 수 있는 위치를 잘 확인해 두세요.
오른쪽 클립 아이콘을 클릭해 바로 복사할 수 있습니다.
기본 정보 페이지의 서버 설정 항목은 앱 서버가 동작을 시작하는 시점에 등록하면 됩니다.
App Secret 발급
앱 개발 후 앱 서버를 시작하기 위해서는 App Secret을 알고 있어야 합니다.
App Secret은 앱 설정의 인증 및 권한 페이지에서 신규 발급 / 재발급 받을 수 있습니다.
(→ 개발자 포털)
앱 서버는 App Secret을 사용해 앱 및 채널 권한의 동작을 수행하기 위한 인증 토큰을 발급 받아야 합니다.
매니저와 유저 권한의 인증 토큰 발급은 채널톡 클라이언트와 앱스토어 사이에서 이루어지는 동작이며 새로 개발되는 앱 서버에서는 매니저와 유저의 인증 토큰을 건네 받아서 검증하거나 새로 발급해주는 행동은 하지 않습니다.
1. App Secret 발급 받기
Secret 항목 오른쪽 발급하기 버튼을 클릭해 새로운 App Secret을 발급할 수 있습니다.
App Secret은 앱의 보안을 위해 발급 후 안전하게 보관해야 하며 외부에 노출된 경우 반드시 새로 발급해 사용해야 합니다.
App Secret에 대한 자세한 설명은 인증 및 권한 페이지에서 확인해주세요.
(→ 인증 및 권한)
권한 설정
본격적으로 앱을 개발하기 전, 앱에서 어떤 동작을 수행할 수 있는지 미리 설정해 두어야 합니다. 이때 앱에서 수행할 수 있는 동작을 권한(Permission)이라고 합니다.
앱 권한은 앱 설정의 인증 및 권한 페이지에서 설정 가능합니다.
(→ 개발자 포털)
클라이언트에서 앱스토어로, 혹은 앱에서 앱스토어로 요청을 보냈을 때 앱 설정에 정의되지 않은 권한을 사용하는 요청은 실패 처리됩니다. 권한 설정은 앱 배포 이후에도 수정이 가능하나 앱의 개발 내용과 실제 동작에 영향을 미치므로 미리 동작 범위를 잘 정의하고 항목을 설정해 두는 것이 중요합니다.
권한 설정 화면에서는 앱에서 사용 가능한 권한의 목록을 확인할 수 있습니다.
권한은 크게 3가지 항목으로 세분화됩니다.
Type | Description | Example |
---|---|---|
Channel | 채널이 할 수 있는 동작에 대한 권한 | 봇 메시지 전송, 유저 및 매니저 정보 확인 등 |
User | 유저가 할 수 있는 동작에 대한 권한 | (유저가) 유저챗 전송 등 |
Manager | 매니저가 할 수 있는 동작에 대한 권한 | 팀챗 전송, 다이렉트챗 전송, (매니저가) 유저챗 전송 등 |
권한 목록을 확인하고 앱에서 사용하려는 권한에 체크 후 저장해 주세요.
Command 정의하기
데스크 앱을 사용하는 매니저, 프론트에 접근하는 유저는 채팅창에 Command를 타이핑해 앱에서 제공하는 기능을 트리거 할 수 있습니다.
Command는 다음 두 가지 타입으로 분류됩니다.
Type | Description |
---|---|
Desk Command | 데스크 웹앱에서 매니저가 실행 가능한 Command |
Front Command | 프론트 웹에서 유저가 실행 가능한 Command |
어떤 사용자에게 어떤 기능을 열어둘 것인지 결정한 후 다음과 같은 형태로 Command를 정의해 주세요. query
에는 앱의 기능을 실행하는데 필요한 값이 채워지게 됩니다.
/<Command Trigger> <query 1> <query 2> ... <query N>
예를 들어, 채널톡 Built-in(기본 제공 앱) 앱의 GIF Command는 다음과 같은 형태로 제공됩니다.
1. 채팅창에 /gif
가 입력된 경우, 최근 인기 있는 GIF 검색어를 추천해 줍니다.
/gif
2. 채팅창에 /gif <search keyword>
가 입력된 경우, 그대로 Command를 실행하면 해당 <search keyword>
에 적합한 GIF 이미지를 보여줍니다.
/gif thumb
이때 검색된 GIF 이미지를 채팅창에 전송하는 동작은 WAM에서 이루어 지게 됩니다.
WAM은 채널 데스크 및 프론트 앱웹과 분리된 별도의 화면을 통해 사용자의 요청을 앱에 중개해 주는 역할을 합니다. WAM에 대한 자세한 내용은 개발자 가이드의 WAM 관련 페이지에서 확인해 주세요.
(→ WAM)
WAM에서 실행하는 동작에 대한 권한도 위에서 언급한 앱 권한 설정에 정의돼 있어야 한다는 점을 기억해 주세요.
이 문서에서 소개하는 샘플 앱은 다음 Command를 채팅창에 입력하고 실행함으로써 데스크에 WAM 화면을 노출합니다.
/tutorial
즉, Command는 WAM 화면을 데스크 혹은 프론트에 노출하는 동시에 필요한 파라미터들을 WAM에 전달해 주기 위한 트리거라고 할 수 있습니다.
앱에서 앱으로, 혹은 다른 매커니즘을 통해 Function을 직접 호출할 경우 채팅창을 통해서 입력하는 Command는 필수적이지 않을 수 있습니다.
Function에 대한 자세한 내용은 개발자 가이드의 Function 관련 페이지에서 확인해 주세요. Command에 대한 추가적인 개념도 개발자 가이드에서 함께 확인할 수 있습니다.
(→ Function)
Function 정의하기
Function은 Command에서 전달 받은 값을 토대로 앱에서 실제 동작을 수행하기 위한 인터페이스입니다.
앱 서버는 각 동작에 대해 다음 인터페이스를 준수해야 합니다.
1. Method, Params, Context로 구성된 JSON으로 요청을 받습니다.
{
"method": "function name", // 실행하려는 동작의 명칭
"params": {
"chat": {
"type": "group | directChat | userChat",
"id": "groupId | directChatId | userChatId"
},
"trigger": {
"type": "string",
"attributes": {}
},
"inputs": {} // inputs는 배열 혹은 object일 수 있습니다.
},
"context": {
"channel": {
"id": "number" // 이 요청이 트리거 된 Channel Id
},
"caller": {
"type": "user | manager | app" ,// 요청을 트리거 한 주체
"id": "number" // caller의 id
}
}
위 요청에서 주목해야 할 점은 다음과 같습니다.
params.chat
및context
의 정보는 앱스토어에서 인증 토큰을 확인해 자동으로 채워주는 값이며 이 값은 신뢰할 수 있습니다.- 특정 channel에서만 동작해야 한다는 규칙이 반드시 보장되어야 할 경우 앱에서
context.channel
의 값을 검증해 주세요. - Command에서 입력한 query 값은
params.inputs
를 통해 전달 받습니다.
즉, Function은 앱에서 수행할 동작과 이때 필요한 값의 명세와 같습니다.
2. 다음과 같은 형태의 JSON 응답을 앱스토어에 돌려주어야 합니다.
The Response of a WAM Function
{
"type": "wam",
"attributes": {
"appId": "app id", // 앱 등록 시 생성 됨
"name": "name of the wam",
"wamArgs": {} // 다른 function 호출 시 이용되어야 할 값의 모음
}
}
The Response of a General Function
{
"result": {
"type": "string",
"attributes": {} // function 처리 결과 혹은 이 function 실행 후 WAM의 연결 동작에 필요한 값의 모음
},
"error": {
"type": "error type",
"message": "error message"
}
}
- Command 실행을 통해 매니저 혹은 유저에게 WAM을 보여주기 위해서는 WAM Function이 필수로 제공되어야 합니다.
wamArgs
에는 WAM에서 그 다음 동작을 위해 필요로 하는 값이나 Command로부터 넘겨 받은 값(query)을 처리한 결과를 담고 있습니다.
예를 들어, 샘플 앱은 다음 두 가지 권한을 필요로 하며 WAM Function을 포함한 두 가지 Function을 제공합니다.
1. 설정된 권한
writeGroupMessage
: 봇이 그룹에 메시지를 작성해요. (앱에서 수행할 동작)writeGroupMessageAsManager
: 매니저가 그룹에 메시지를 작성해요. (WAM에서 수행할 동작)
2. WAM Function
Request
{
"method": "tutorial",
"params": null, // wam을 호출하기 위해 아무런 query를 필요로 하지 않습니다.
"context": {
"caller": {
"type": "manager", // 앱은 WAM에서 '매니저로 그룹에 메시지 전송' 권한만 사용하도록 설정하였기 때문에 caller type(wam을 요청한 주체)이 매니저인지 검증해야 합니다.
"id": "managerId"
}
}
}
Response
{
"type": "wam",
"attributes": {
"appId": "app id", // app-tutorial의 app id
"name": "tutorial",
"wamArgs": {
"message": "This is a test message sent by a manager.", // 매니저는 여기서 제공하는 문장을 wam 인터페이스를 통해 그룹 메시지로 전송합니다.
"managerId": "caller.id" // wam에서는 이 값을 통해 어떤 매니저가 wam을 트리거 해 그룹 메시지를 전송하려고 시도하는지 알 수 있습니다.
}
}
3. General Function to Send a Group Message as a Manager
Request
{
"method": "sendAsBot",
"params": {
"groupId": "group id", // 어떤 그룹에 메시지를 작성할까요?
"rootMessageId": "root message id", // 메시지를 가장 상위 그룹 채팅창에 작성하지 않고 스레드에 작성하고자 할 경우 이 값(스레드의 첫 번째 메시지의 id)이 같이 주어져야 합니다.
"broadcast": true // 이 그룹 메시지를 스레드에 작성할 때, 스레드 바깥의 상위 그룹 채팅창에 내용을 함께 전파합니다.
},
"context": {
"channel": {
"id": "channel id" // 이 채널에 그룹 메시지를 전송합니다.
},
"caller": null // 봇 메시지 전송 동작이므로 function 트리거 주체는 고려하지 않습니다. 즉, 검증하지 않습니다.
}
Response
{
"result": {
"type": "string",
"attributes": {} // 봇 메시지 전송 성공 여부와 관계 없이 빈 값을 반환합니다. (이후로 아무 동작도 실행하지 않고 wam을 종료합니다.)
}
}
위 내용을 참고해 앱에서 제공하려는 기능 별로 Function을 각각 정의해 주세요. 이때 Function의 Method는 서로 다른 앱 사이에서 중복될 수 있습니다.
Function에 대한 더 자세한 내용은 개발자 가이드의 Function 관련 페이지를 확인해 주세요.
(→ Function)
앱 개발
실제 앱 개발 시 고려해야 할 사항은 다음과 같습니다.
- 환경 설정
- 앱 및 채널 타입 권한을 위한 인증 토큰의 발급 및 캐싱
- WAM 및 WAM에 대한 정적 페이지(WAM Endpoint) 제공
- 앱 서버 실행과 동시에 Command 등록
- 각 Function과 Function Endpoint의 구현
아래 내용을 참고해 하나씩 구현해 봅시다!
1. 환경 설정
앱은 런타임에 다음 값을 사용할 수 있어야 합니다.
Field | Description |
---|---|
App ID | 앱 등록 시 확인할 수 있습니다. |
App Secret | 앱 설정의 - 인증 및 권한에서 발급 받을 수 있습니다. |
App Store Endpoint | https://app-store-api.channel.io |
앱 서버에서 환경 변수를 잘 사용할 수 있도록 준비해 주세요.
2. 앱 및 채널 타입 권한을 위한 인증 토큰 발급 및 캐싱
앱 서버에서는 매니저와 유저에 대한 토큰 발급 및 캐싱에는 관심을 두지 않습니다.
이 역할은 앱스토어가 제공하는 Wam-controller에서 대신 도맡아서 해 준답니다.
Wam-controller에 대한 자세한 내용은 개발자 가이드의 해당 페이지를 확인해 주세요.
(→ WAM)
앱 서버에서 채널 타입 동작을 수행하지 않는 경우 3번 항목으로 바로 넘어가 주세요.
앱 서버에서 관리해야 할 토큰의 타입은 두 가지입니다.
여기서 앱 타입 토큰에 대한 자세한 설명은 4번 항목에서 확인해 주세요.
Type | Description | Need to cache? |
---|---|---|
App | 앱이 할 수 있는 동작에 대한 권한 | X |
Channel | 채널이 할 수 있는 동작에 대한 권한 | O |
앱 설정에서 저장한 채널 타입 동작을 앱 서버가 정상적으로 수행하기 위해서는 채널 타입 토큰을 반복해서 발급하고 이 값을 캐싱하는 로직이 반드시 필요합니다.
캐싱
캐시 메모리는 프로젝트 전체에서 전역적으로 접근 가능해야 합니다.
여기서 중요한 포인트는 토큰에는 지속 시간이 존재하며 캐싱된 토큰은 이 시간이 지나면 소멸되어야 한다는 점입니다.
Type | Description | Duration |
---|---|---|
Access Token | 실제 인증 정보를 가진 토큰, Function 호출에 사용되는 토큰 | 30 Min. |
Refresh Token | Access Token을 재발급 하기 위한 토큰 | 7 Days |
또한 Access Token 발급에는 요청에 대한 rate-limit이 존재하기 때문에 토큰의 캐싱이 필요합니다.
이에 대한 자세한 설명은 개발자 가이드의 인증 및 권한 부분에서 확인할 수 있습니다.
(→ 인증 및 권한)
사용 캐싱 라이브러리, 캐싱의 구현은 구현 언어 및 앱의 동작에 따라 다를 수 있으므로 이 문서에서는 생략합니다. 코드 전문은 GitHub에서 확인할 수 있습니다.
(→ 캐시 구현 코드)
캐시 키와 토큰 지속 시간 설정
샘플 앱에서는 다음과 같은 방법으로 각 토큰 타입에 맞는 지속 시간과 캐시 키를 구할 수 있도록 구현했습니다.
// Token은 무조건 Unique Key와 Duration을 가지고 있습니다.
type Token interface {
Key() string
Duration() time.Duration
}
type AccessToken struct {
ChannelID string // 채널 토큰이므로 ChannelID 마다 토큰을 발급 받아야 합니다.
Token string
}
func (t AccessToken) Key() string {
return fmt.Sprintf("app-%s-access-token-%s", config.Get().AppID, t.ChannelID)
}
// real duration is 30 minutes
func (AccessToken) Duration() time.Duration {
return time.Minute*30 - time.Minute*1
}
type RefreshToken struct {
ChannelID string // 채널 토큰이므로 ChannelID 마다 토큰을 발급 받아야 합니다.
Token string
}
func (t RefreshToken) Key() string {
return fmt.Sprintf("app-%s-refresh-token-%s", config.Get().AppID, t.ChannelID)
}
// real duration is 7 days
func (RefreshToken) Duration() time.Duration {
return time.Hour*24*7 - time.Minute*1
}
토큰 정의 시 다음 사항을 고려해 주세요.
- 캐시 키는 정해진 것이 아니며 ChannelID에 대해 유일한 값으로만 정의되면 됩니다.
- 서버에서 실제 요청이 처리 되는 시간을 고려해 Access Token과 Refresh Token의 실제 지속 시간보다 살짝 작은 값을 캐시의 TTL로 설정했습니다. (
Duration()
메서드를 확인해 주세요.) - Duration을 앱 서버 코드에서 정의하지 않고 토큰 발급 응답으로 돌아오는 expires_in, expired_at 값을 활용할 수도 있습니다.
위 코드는 GitHub에서 확인할 수 있습니다.
(→ 토큰 모델 구현 코드)
토큰 발급 요청
이제 토큰을 실제로 발급해 봅시다!
다음 두 가지 Method의 Native Function을 앱스토어에 요청해 새 토큰을 발급 받거나 기존에 발급 받은 토큰을 갱신할 수 있습니다.
issueToken
refreshToken
Native Function은 다음과 같은 형태를 가집니다.
type NativeFunctionRequest = {
method: string // issueToken | refreshToken
params: any // IssueTokenParams | RefreshTokenParams
};
type NativeFunctionResponse = {
error: NativeError
result: any // TokenResponse
};
type NativeError = {
type: string
message: string
};
type NativeFunctionRequest[REQ any] struct {
Method string // issueToken | refreshToken
Params REQ // IssueTokenParams | RefreshTokenParams
}
type NativeFunctionResponse struct {
Error NativeError `json:"error,omitempty"`
Result json.RawMessage `json:"result,omitempty"` // TokenResponse
}
type NativeError struct {
Type string `json:"type"`
Message string `json:"message"`
}
Native Function에 대한 자세한 내용은 개발자 가이드의 Function 관련 페이지를 확인해 주세요.
(→ Function)
Native Function의 Params
와 Result
에는 다음 값이 들어가게 됩니다.
// NativeFunctionRequest.Params
type IssueTokenParams = {
secret: string
channelID: string
}
// NativeFunctionRequest.Params
type RefreshTokenParams = {
refreshToken: string
}
// NativeFunctionResponse.Result
type TokenResponse = {
accessToken: string
refreshToken: string
}
// NativeFunctionRequest.Params
type IssueTokenParams struct {
Secret string `json:"secret"`
ChannelID string `json:"channelId"`
}
// NativeFunctionRequest.Params
type RefreshTokenParams struct {
RefreshToken string `json:"refreshToken"`
}
// NativeFunctionResponse.Result
type TokenResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
실제 토큰 발급 요청 성공 시 돌아오는 응답에는 더 많은 정보가 필요하지만 샘플 앱은 AccessToken
과 RefreshToken
값만 사용하기 때문에 TokenResponse
에는 이 두 값만 정의해 두었습니다.
실제 토큰 요청 및 응답에 대해서는 개발자 가이드의 인증 및 권한 부분을 참고해 주세요.
(→ 인증 및 권한)
다음은 위 DTO를 사용해 앱스토어에 특정 채널에 대한 새로운 토큰 발급을 요청하는 예제입니다.
async function requestIssueToken(channelId?: string) : Promise<[string, string, number]> {
let body = {
method: 'issueToken',
params: {
secret: appConfig.appSecret,
channelId: channelId
}
};
const headers = {
'Content-Type': 'application/json'
};
const response = await axios.put(appConfig.appstoreURL, body, { headers });
const accessToken = response.data.result.accessToken;
const refreshToken = response.data.result.refreshToken;
const expiresAt = new Date().getTime() / 1000 + response.data.result.expiresIn - 5;
return [accessToken, refreshToken, expiresAt];
}
const [accessToken, refreshToken, expiresAt]: [string, string, number] | undefined = await requestIssueToken(channelId);
func (c *authClient) IssueToken(ctx context.Context, channelID string) (*dto.TokenResponse, error) {
body := native.NativeFunctionRequest[dto.IssueTokenParams]{
Method: "issueToken",
Params: dto.IssueTokenParams{
Secret: config.Get().AppSecret,
ChannelID: channelID,
},
}
res, err := c.R().
SetHeader("Content-Type", "application/json").
SetBody(body).
Put("/general/v1/native/functions")
if err != nil || res.IsError() {
return nil, errors.Wrapf(err, "failed to request issueToken")
}
var nres native.NativeFunctionResponse
if err := json.Unmarshal(res.Body(), &nres); err != nil {
return nil, errors.Wrapf(err, "failed to request issueToken")
}
var tres dto.TokenResponse
if err := json.Unmarshal(nres.Result, &tres); err != nil {
return nil, errors.Wrapf(err, "failed to request issueToken")
}
return &tres, nil
}
중요하게 보아야 할 점은 다음과 같습니다.
- App Secret는 환경 변수에서 가져옵니다.
- 토큰은 각
ChannelID
마다 발급해야 합니다. - 요청은
PUT https://app-store-api.channel.io/general/v1/native/function
로 전송합니다.
이렇게 발급받은 토큰은 캐시에 저장해 두었다가 새로 요청을 보낼 때 사용할 수 있습니다.
Refresh Token의 지속시간이 더 길기 때문에 이미 발급 받은 Access Token이 만료 되었을 경우에는 Refresh Token을 사용해 토큰을 갱신할 수 있습니다. 토큰의 갱신은 refreshToken
Function을 사용해 토큰 발급과 같은 방법으로 수행하면 됩니다.
전체 인증 로직의 구현은 GitHub에서 확인할 수 있습니다.
(→ 인증 구현 코드)
3. WAM 및 WAM에 대한 정적 페이지(WAM Endpoint) 제공
이제 매니저와 유저가 Command를 실행했을 때 화면에 보여줄 WAM 화면을 만들어야 합니다.
개발자 가이드의 WAM 및 Wam-controller 관련 페이지를 참고해 WAM을 구현해 주세요.
(→ WAM)
GitHub에서 샘플 앱의 WAM 구현 예시를 확인할 수 있습니다. 샘플 앱의 경우 봇 프로필 메시지 전송 동작(sendAsBot)은 앱 서버가, 매니저로 메시지 전송 동작은 WAM에서 담당하고 있다는 사실을 잊지 마세요.
(→ WAM 예제
WAM 구현 후 요청이 들어왔을 때 앱스토어가 이 WAM에 접근할 수 있도록 WAM Endpoint를 제공해 주어야 합니다.
예를 들어, 샘플 앱에서는 다음과 같은 Handler를 추가했습니다.
app.use(express.json());
app.use(`/resource/wam/${WAM_NAME}`, express.static(path.join(__dirname, '../client/dist')));
app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});
// handler와 wam 파일의 상대적인 위치
//
// wam/
// resources/wam/tutorial/
// #index.html -> 빌드된 WAM 페이지
// ...
// #handler.go
//go:embed resources/*
var resources embed.FS
type Handler struct {
}
func NewHandler() *Handler {
return &Handler{}
}
func (h *Handler) Path() string {
// <샘플 앱의 루트 엔드포인트>/resource/wam/tutorial로 WAM에 접근할 수 있도록 열어줍니다.
return "/resource/wam/tutorial"
}
func (h *Handler) Register(router libhttp.Router) { // libhhttp.Router는 gin.IRouter의 구현체입니다.
static, err := fs.Sub(resources, "resources/wam/tutorial")
if err != nil {
panic(err)
}
// 이 엔드포인트에 접근하면 wam의 정적 페이지를 얻을 수 있습니다.
router.StaticFS("", http.FS(static))
}
실제 앱스토어에서는 이런 방식으로 WAM Path를 계산해 WAM에 접근합니다.
<서버 설정에 등록한 WAM Endpoint>/<해당 Command의 Action Function Name>
따라서 샘플 앱의 서버 설정에 등록할 WAM Endpoint의 실제 값은 <앱 서버의 루트 엔드포인트>/resource/wam
이 됩니다.
4. 앱 서버 실행과 동시에 Command 등록
권한 설정 파트에서 권한의 타입에는 Channel, User, Manager 세 가지가 있다고 한 것을 기억하시나요?
사실 앱 설정 화면에서 선택하지 않아도 자동으로 등록되는 권한과 권한 타입이 한 가지 더 있습니다.
바로 App 권한입니다.
Type | Description | Example |
---|---|---|
Channel | 채널이 할 수 있는 동작에 대한 권한 | 봇 메시지 전송, 유저 및 매니저 정보 확인 등 |
User | 유저가 할 수 있는 동작에 대한 권한 | (유저가) 유저챗 전송 등 |
Manager | 매니저가 할 수 있는 동작에 대한 권한 | 팀챗 전송, 다이렉트챗 전송, (매니저가) 유저챗 전송 등 |
App | 앱이 할 수 있는 동작에 대한 권한 | Command의 등록 등 ← NEW! ✨ |
앱 서버는 실행과 동시에 Command를 앱스토어에 등록해야 합니다.
앱은 앱 타입 토큰을 사용해 자신이 앱 권한을 가지고 있다는 사실을 앱스토어에 증명합니다. Command 등록은 앱 서버 시작 시 최초 1 회만 수행되므로 앱 토큰을 서버에 계속 저장해 둘 필요는 없습니다.
앱 토큰을 발급하는 방법은 아주 간단합니다! 토큰 발급 파트에서 설명한 채널 타입 토큰 발급과 완전히 똑같습니다.
유일하게 다른 부분은 토큰을 발급 할 때 Params
의 필드에 채널 아이디를 넘겨주지 않는다는 점입니다.
const [accessToken, refreshToken, expiresAt]: [string, string, number] | undefined = await requestIssueToken();
//await requestIssueToken(channelId)의 경우 해당 channelId를 갖는 채널에 대한 토큰이 발급됩니다.
func (c *authClient) IssueAppToken(ctx context.Context) (*dto.TokenResponse, error) {
body := native.NativeFunctionRequest[dto.IssueTokenParams]{
Method: "issueToken",
Params: dto.IssueTokenParams{
Secret: config.Get().AppSecret,
// 요청에 ChannelID가 없으면 채널 토큰이 아닌 앱 토큰을 발급합니다.
// ChannelID: channelID,
},
}
...
}
정확한 토큰 발급 요청 및 응답에 대해서는 개발자 가이드의 인증 및 권한 부분을 참고해 주세요.
(→ 인증 및 권한)
Command의 등록은 토큰 발급과 같이 Native Function을 사용합니다.
아래에 보이는 RegisterCommandsParam
을 토큰 발급 파트에서 보았던 Native Function의 Params
에 넣어 요청하면 됩니다. 즉, 앞서 사용했던 IssueTokenParams
대신 RegisterCommandsParams
을 사용해 주면 됩니다.
type Command = {
name: string
scope: string
description: string
actionFunctionName: string
alfMode: string
enabledByDefault: bool
}
type RegisterCommandsParam = {
appId: string
commands: Command[]
}
type Command struct {
Name string `json:"name"`
Scope string `json:"scope"`
Description string `json:"description"`
ActionFunctionName string `json:"actionFunctionName"`
ALFMode string `json:"alfMode"`
EnabledByDefault bool `json:"enabledByDefault"`
}
type RegisterCommandsParam struct {
AppID string `json:"appId"`
Commands []Command `json:"commands"`
}
다음 사항들을 유의해 주세요.
- 토큰 발급 및 갱신 요청은 보안을 위해 App Secret을 필요로 하며, 이 값은 Native Function의
Params
에 넣어 전달합니다. - Command 등록 요청은 보안을 위해 앱 토큰을 필요로 하며, 이 값은 요청 헤더(
x-access-token
)에 넣어 전달합니다. - Command 등록 요청은 서버가 시작되는 동시에 수행되어야 합니다.
Command 등록에 대한 코드 전문은 GitHub에서 확인해 주세요.
(→ Command 등록 코드)
Command 등록에 대한 정확한 요청 및 응답에 대해서는 개발자 가이드의 Command 관련 페이지를 확인해 주세요.
(→ Command)
5. 각 Function과 Function Endpoint의 구현
여기서는 Function 정의하기 파트에서 정의했던 샘플 앱의 Function과 실제 처리 로직을 코드로 구현하려고 합니다.
(→ Function)
샘플 앱은 다음 두 가지 Function을 제공합니다.
Method | Operation |
---|---|
tutorial | WAM Argument를 내려줍니다. (WAM Function) |
sendAsBot | 봇 프로필로 그룹 메시지를 작성합니다. |
Function Handler
앱으로 들어오는 Function 요청을 받을 Endpoint를 설정해야 합니다.
일반적으로 <root path>/functions
형태의 Endpoint가 사용됩니다. 이 값은 나중에 앱 설정 페이지의 서버 설정에 사용될 Function Endpoint가 됩니다.
아래와 같이 Handler 코드를 작성하세요.
async function functionHandler(body: any) {
const method = body.method;
const callerId = body.context.caller.id;
const channelId = body.context.channel.id;
switch (method) {
case 'openWam':
return openWam(WAM_NAME, callerId);
case 'writeGroupMessageAsBot':
return await writeGroupMessageAsBot(
channelId,
body.params.chatId,
body.params.text,
body.params.rootMessageId
);
}
}
app.use(express.json());
app.put('/function', (req: Request, res: Response) => {
if (typeof req.headers['x-signature'] !== 'string' || verification(req.headers['x-signature'], JSON.stringify(req.body)) === false) {
res.status(401).send('Unauthorized');
}
functionHandler(req.body).then(result => {
res.send(result);
});
});
app.listen(appConfig.port, () => {
console.log(`Server is running at http://localhost:${appConfig.port}`);
});
// ...
func (h *Handler) Path() string {
// /functions endpoint로 요청을 받습니다.
return "/functions"
}
func (h *Handler) Register(router libhttp.Router) {
// PUT /functions로 요청이 들어오면 Handler.Function 메서드로 요청을 처리합니다.
router.PUT("", h.Function)
}
func (h *Handler) Function(ctx *gin.Context) {
var req dto.JsonFunctionRequest
if err := ctx.ShouldBindBodyWith(&req, binding.JSON); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var res *dto.JsonFunctionResponse
switch req.Method {
case tutorialMethod:
res = h.tutorial(ctx, req.Params.Input, req.Context)
case sendAsBotMethod:
res = h.sendAsBot(ctx, req.Params.Input, req.Context)
default:
ctx.JSON(
http.StatusOK,
dto.JsonFunctionResponse{
Error: &dto.Error{
Message: "invalid method, " + req.Method,
},
},
)
return
}
ctx.JSON(http.StatusOK, res)
}
JsonFunctionRequest
는 Function 정의하기 파트에서 설명한 Method, Params, Context를 가진 JSON 요청을 go의 struct로 정의한 것입니다.JsonFunctionRequest
에서 Method를 보고 어떤 Function인지(tutorial? sendAsBot?) 판단해 각각 처리합니다.- 에러가 발생한 경우에도 200 OK 상태 코드와 함께 인터페이스에 맞는 형식의 응답을 내려주어야 합니다.
요청 및 응답 형식에 대해서는 이 문서의 Function 정의하기 파트를 참고해 주세요.
tutorial (WAM Function)
JsonFunctionRequest
의 Method가 tutorial일 경우 다른 동작 없이 다음 응답을 바로 내려주면 됩니다.
{
"type": "wam",
"attributes": {
"appId": "...", // app-tutorial의 app id
"name": "tutorial",
"wamArgs": {
"message": "This is a test message sent by a manager.", // 매니저는 여기서 제공하는 문장을 wam 인터페이스를 통해 그룹 메시지로 전송합니다.
"managerId": "caller.id" // wam에서는 이 값을 통해 어떤 매니저가 wam을 트리거 해 그룹 메시지를 전송하려고 시도하는지 알 수 있습니다.
}
}
sendAsBot (General Function)
이 Function이 앱 서버에 들어오면 앱 서버는 봇 프로필로 그룹 메시지를 전송해 줍니다.
async function writeGroupMessageAsBot(channelId: string, chatId: string, text: string, rootMessageId?: string) {
const body = {
method: "writeGroupMessage",
params: {
channelId: channelId,
groupId: chatId,
rootMessageId: rootMessageId,
broadcast: false,
dto: {
plainText: text,
botName: "Bot"
}
}
}
const channelToken = await getChannelToken(channelId);
const headers = {
'x-access-token': channelToken[0],
'Content-Type': 'application/json'
};
const response = await axios.put(appConfig.appstoreURL, body, { headers });
if (response.data.error !== undefined) {
throw new Error();
}
return ({'result': {}});
}
func (h *Handler) sendAsBot(
ctx context.Context,
params json.RawMessage,
fnCtx dto.Context,
) *dto.JsonFunctionResponse {
// ...
// parsing 로직 생략
_, err := h.client.WritePlainTextToGroup(
ctx,
appstoredto.PlainTextGroupMessage{
ChannelID: fnCtx.Channel.ID,
GroupID: param.GroupID,
Broadcast: param.Broadcast,
RootMessageID: param.RootMessageID,
Message: sendAsBotMsg,
},
)
if err != nil {
return &dto.JsonFunctionResponse{
Error: &dto.Error{
Message: "failed to send message as a bot",
},
}
}
// response parsing 로직 생략
}
이때 WritePlainTextToGroup(...)
은 App Store 서버로 writeGroupMessage의 Native Function를 요청하는 함수입니다.
해당 함수를 호출할 때 요청의 params에 들어있는 값을 꺼내 사용하는 것을 잘 봐주세요. Command를 통해 여기에 다른 query를 더 추가해 줄 수 있습니다.
Native Function 요청 및 위와 관련된 코드 전문은 GitHub에서 확인할 수 있습니다.
(→ Handler 구현)
추가적인 Function이 필요한 경우 마음껏 추가하세요!
서버 설정
개발된 앱을 잘 동작하게 하려면 앱 설정에서 Function Endpoint와 WAM Endpoint를 등록해 주어야 합니다.
(→ 개발자 포털)
앱 개발 파트를 잘 따라가면 앱 서버에 두 가지 API Endpoint를 만들 수 있습니다.
Type | Description |
---|---|
Function Endpoint | Function을 요청하고 결과를 받기 위한 API URL |
WAM Endpoint | WAM Arguments를 요청하고 결과를 받기 위한 API URL |
기본 정보 → 서버 설정에서 값을 잘 넣고 저장해 주세요.
Signing Key에 대해서는 설정 화면에 기재된 가이드를 확인해 주세요.
샘플 앱의 Function Endpoint와 WAM Endpoint는 다음과 같습니다.
Type | Description |
---|---|
Function Endpoint | Function을 요청하고 결과를 받기 위한 API URL |
WAM Endpoint | WAM Arguments를 요청하고 결과를 받기 위한 API URL |
샘플 앱은 ngrok을 사용해 로컬 환경에서 테스트 가능합니다! 자세한 내용은 테스트 파트를 참고해 주세요.
앱 설치
데스크의 앱스토어 플랫폼에서 내가 등록한 앱을 확인 할 수 있습니다.
앱 설정을 전체 공개(Public)으로 설정하지 않은 경우 앱 목록 하단의 내가 만든 비공개 앱에서 등록한 앱을 확인할 수 있습니다. 해당하는 앱 카드를 클릭해 앱 화면으로 이동해 봅시다.
1. 설치하기
앱 화면으로 이동하면 오른쪽의 설치하기 버튼을 확인할 수 있어요. 버튼을 클릭해 설치를 시작합니다.
앱을 설치하기 전 앱 등록 시 설정했던 권한이 잘 들어있는지 확인합니다. 권한에 문제가 없으면 확인 버튼을 클릭해 앱을 설치해 주세요.
2. 앱 서버를 시작하세요!
이제 채널톡 채팅창에서 앱과 Command를 사용할 수 있게 되었습니다!
3. Command 확인하기
앱 서버가 동작을 시작하면 앱 정보 화면에 커맨드 탭이 나타난 걸 확인할 수 있어요. 커맨드 탭에서는 매니저 및 유저가 트리거 할 수 있는 커맨드 목록과 커맨드 사용하기 토글을 볼 수 있습니다.
테스트
앱 서버 Endpoint를 완전히 공개하고 앱을 전체 공개로 배포하기 전, 로컬 환경에서 WAM과 Function을 테스트 해 주세요.
이 문서에는 ngrok을 사용해 로컬 환경에서 샘플 앱을 테스트 하는 방법을 소개합니다. ngrok이 아직 준비되지 않은 경우 해당 페이지를 참고해 도구를 준비해 주세요.
다른 방법을 사용하고자 할 경우 꼭 이 방법을 따르지 않아도 무방합니다. 아래 내용을 참고해 직접 개발한 앱을 테스트 해보셔도 됩니다.
1. 프로젝트 내려받기
GitHub에서 app-tutorial 프로젝트를 내려받아 주세요.
$ git clone https://github.com/channel-io/app-tutorial
2. 앱 환경 설정 변수 준비하기
샘플 앱의 환경 변수 파일에 필요한 정보를 기입합니다.
채널톡은 샘플 앱의 App ID와 App Secret을 공개하지 않습니다.
샘플 앱을 테스트 해 보고자 할 경우 우선 앱 등록에서 테스트 앱을 하나 만들어 필요한 값을 준비해 주세요. 새로 만든 테스트 앱은 writeGroupMessage
와 writeGroupMessageAsManager
권한을 반드시 가지고 있어야 합니다.
stage: development
appId: # app id registered in app-store
appSecret: # app secret issued by a manager
api:
public:
http:
port: 3022
appStore:
baseUrl: https://app-store-api.channel.io
bot:
name: AppTutorialBot
stage
는 그대로 두는 것을 권장합니다.appId
,appSecret
은 준비된 값을 넣어주세요.
3. 앱 서버 실행하기
README의 설명에 따라 앱을 실행해 주세요.
$ make dev
4. ngrok 실행하기
앱스토어에서 보내주는 요청을 샘플 앱을 실행 중인 로컬 Endpoint로 포워드 해주기 위해 ngrok을 실행합니다.
ngrok을 실행할 때 넘겨주는 전달 인자들은 테스트 하는 환경에 따라 다를 수 있습니다.
$ ngrok http http://localhost:3022 --subdomain app-tutorial --region ap
ngrok을 실행 후 전환되는 화면에서 ngrok에서 제공하는 Endpoint를 확인할 수 있습니다. 이 값이 Function Endpoint와 WAM Endpoint의 루트 Endpoint가 됩니다.
ngrok (Ctrl+C to quit)
Full request capture now available in your browser: https://ngrok.com/r/ti
Session Status online
Account <Your Account> (Plan: ...)
Version 3.9.0
Region Asia Pacific (ap)
Latency 83ms
Web Interface http://127.0.0.1:4040
Forwarding https://app-tutorial.ap.ngrok.io -> http://localhost:3022
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
여기서 우리는 샘플 앱의 Endpoint가 https://app-tutorial.ap.ngrok.io
가 되는 것을 알 수 있습니다.
5. 앱 설정에 Endpoint 등록하기
이 문서의 서버 설정 파트를 참고해 테스트 앱의 설정 페이지에서 Function Endpoint와 WAM Endpoint를 저장해 주세요.
여기서 두 값은 각각 다음과 같습니다.
- Function Endpoint:
https://app-tutorial.ap.ngrok.io/functions
- WAM Endpoint:
https://app-tutorial.ap.ngrok.io/resource/wam
6. 앱 설치하기
이 문서의 앱 설치하기 파트를 참고해 테스트 채널에 앱을 설치해 주세요.
새로 등록한 테스트 앱은 앱이 설치된 채널에서만 사용할 수 있습니다.
7. Command 사용해 보기
채팅창에 Command를 입력해 보세요!
Command 입력
WAM 띄우기 (Command 실행)
봇 및 매니저로 그룹 메시지 작성
각 버튼을 클릭하면 다음과 같은 메시지가 그룹 챗에 전송됩니다.
채널 앱을 개발하기 위한 튜토리얼이 모두 끝났습니다!