안녕하세요! 😁

채널 앱 개발 튜토리얼에 오신 것을 환영합니다.

이 문서를 통해 이런 작업을 할 수 있어요.

  • 앱스토어(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에서 확인을 클릭하면 기본 정보 입력 화면으로 넘어옵니다.


각 항목에 필요한 정보를 입력해주세요.

TypeDescription
앱 아이콘앱스토어 및 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가지 항목으로 세분화됩니다.

TypeDescriptionExample
Channel채널이 할 수 있는 동작에 대한 권한봇 메시지 전송, 유저 및 매니저 정보 확인 등
User유저가 할 수 있는 동작에 대한 권한(유저가) 유저챗 전송 등
Manager매니저가 할 수 있는 동작에 대한 권한팀챗 전송, 다이렉트챗 전송, (매니저가) 유저챗 전송 등

권한 목록을 확인하고 앱에서 사용하려는 권한에 체크 후 저장해 주세요.



Command 정의하기


데스크 앱을 사용하는 매니저, 프론트에 접근하는 유저는 채팅창에 Command를 타이핑해 앱에서 제공하는 기능을 트리거 할 수 있습니다.


Command는 다음 두 가지 타입으로 분류됩니다.

TypeDescription
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.chatcontext의 정보는 앱스토어에서 인증 토큰을 확인해 자동으로 채워주는 값이며 이 값은 신뢰할 수 있습니다.
  • 특정 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. 환경 설정

앱은 런타임에 다음 값을 사용할 수 있어야 합니다.

FieldDescription
App ID앱 등록 시 확인할 수 있습니다.
App Secret앱 설정의 - 인증 및 권한에서 발급 받을 수 있습니다.
App Store Endpointhttps://app-store-api.channel.io

앱 서버에서 환경 변수를 잘 사용할 수 있도록 준비해 주세요.


2. 앱 및 채널 타입 권한을 위한 인증 토큰 발급 및 캐싱

앱 서버에서는 매니저와 유저에 대한 토큰 발급 및 캐싱에는 관심을 두지 않습니다.

이 역할은 앱스토어가 제공하는 Wam-controller에서 대신 도맡아서 해 준답니다.

Wam-controller에 대한 자세한 내용은 개발자 가이드의 해당 페이지를 확인해 주세요.

(→ WAM)


앱 서버에서 채널 타입 동작을 수행하지 않는 경우 3번 항목으로 바로 넘어가 주세요.


앱 서버에서 관리해야 할 토큰의 타입은 두 가지입니다.

여기서 앱 타입 토큰에 대한 자세한 설명은 4번 항목에서 확인해 주세요.

TypeDescriptionNeed to cache?
App앱이 할 수 있는 동작에 대한 권한X
Channel채널이 할 수 있는 동작에 대한 권한O

앱 설정에서 저장한 채널 타입 동작을 앱 서버가 정상적으로 수행하기 위해서는 채널 타입 토큰을 반복해서 발급하고 이 값을 캐싱하는 로직이 반드시 필요합니다.


캐싱

캐시 메모리는 프로젝트 전체에서 전역적으로 접근 가능해야 합니다.


여기서 중요한 포인트는 토큰에는 지속 시간이 존재하며 캐싱된 토큰은 이 시간이 지나면 소멸되어야 한다는 점입니다.

TypeDescriptionDuration
Access Token실제 인증 정보를 가진 토큰, Function 호출에 사용되는 토큰30 Min.
Refresh TokenAccess 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의 ParamsResult에는 다음 값이 들어가게 됩니다.

// 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"`
}

실제 토큰 발급 요청 성공 시 돌아오는 응답에는 더 많은 정보가 필요하지만 샘플 앱은 AccessTokenRefreshToken값만 사용하기 때문에 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 권한입니다.

TypeDescriptionExample
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을 제공합니다.

MethodOperation
tutorialWAM 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 EndpointWAM Endpoint를 등록해 주어야 합니다.

(→ 개발자 포털)


앱 개발 파트를 잘 따라가면 앱 서버에 두 가지 API Endpoint를 만들 수 있습니다.

TypeDescription
Function EndpointFunction을 요청하고 결과를 받기 위한 API URL
WAM EndpointWAM Arguments를 요청하고 결과를 받기 위한 API URL

기본 정보 → 서버 설정에서 값을 잘 넣고 저장해 주세요.


Signing Key에 대해서는 설정 화면에 기재된 가이드를 확인해 주세요.


샘플 앱의 Function Endpoint와 WAM Endpoint는 다음과 같습니다.

TypeDescription
Function EndpointFunction을 요청하고 결과를 받기 위한 API URL
WAM EndpointWAM 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을 공개하지 않습니다.

샘플 앱을 테스트 해 보고자 할 경우 우선 앱 등록에서 테스트 앱을 하나 만들어 필요한 값을 준비해 주세요. 새로 만든 테스트 앱은 writeGroupMessagewriteGroupMessageAsManager 권한을 반드시 가지고 있어야 합니다.

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 실행)


봇 및 매니저로 그룹 메시지 작성

각 버튼을 클릭하면 다음과 같은 메시지가 그룹 챗에 전송됩니다.


채널 앱을 개발하기 위한 튜토리얼이 모두 끝났습니다!