跳到主要內容

鑽石級贊助商 - KKBOX 帶你打造具備 NLP 功能的 Telegram Bot(下)

打造具備 NLP 功能的 Telegram Bot(下)

上一篇文章已經讓 Chatbot 有了許多自然應答的功能,透過 OLAMI 預設的 IDS 對話模組也能處理多數詢問,但如果想要讓內容更多元,就需要仰賴內容供應商的資料來豐富對話內容,例如:希望它幫我找動漫歌曲,同時列出漂亮的專輯封面圖供選擇

這一篇內容會介紹要怎麼整合更多內容進 Chatbot,同時也會介紹如何將 Chatbot 部署到 Heroku 上,正式在網路上提供服務給所有使用者~

使用的工具及服務:

  1. Python 3(for develop)
  2. pipenv(for dependency management)
  3. OLAMI(for NLP)
  4. KKBOX Open API(for third-party skill import)
  5. ngrok(for testing)
  6. Heroku(for deploying our chatbot)

Step 7. Add custom skill into chatbot

OLAMI NLI 系統提供方法,讓我們可以定義 Intent、符合該 Intent 的句型和句型中的 slot。舉例來說,我可以設定 播放<keyword>類型的歌 的句型屬於 music_play_playlist Intent; <keyword> 則是句型中重要的 slot,讓 Chatbot 根據 <keyword> fetch 對應的 data 提供給使用者。
音樂資料從 KKBOX Open API 取得,因為 KKBOX 日本歌曲比較多。API 裡面的 search method 提供搜尋功能,把音樂資料分為 track、album、artist、playlist 四種類型。可以根據這四種類型定義四種 Intent 與符合的句型,然後用句型中的 slot 作為 request KKBOX search API 的 query parameter,最後將搜尋結果回傳給使用者。
從 OLAMI 我的應用介面 => 進入 NLI 系統 => 新增模組 => 名稱命名:music_kkbox => 提交

點選左側選單中的我的語法 => +新增語法
在這裡新增四種 Intent
  1. music_play_track
  2. music_play_album
  3. music_play_artist
  4. music_play_playlist
四種 slot(類型選擇 ext)
  1. track_name
  2. album_name
  3. artist_name
  4. keyword
最後組合成 4 種句型
  1. 播放<track_name><{@=music_play_track}>
  2. 播放<album_name>專輯的歌<{@=music_play_album}>
  3. 播放<artist_name>的歌<{@=music_play_artist}>
  4. 播放<keyword>類型的歌<{@=music_play_playlist}>

OSL(OLAMI Syntax Language) 語法教學文件
完成後的結果


點選畫面右上角發佈 => 回到 OLAMI 我的應用介面 => 對 App 點選變更設定 => 將 NLI 模組 => music_kkbox 模組打勾 => 儲存設定

在 OLAMI 我的應用介面點選測試,測試語句打 播放動漫歌曲類型的歌,會得到 Response
{
   "nli":[
      {
         "desc_obj":{
            "status":0
         },
         "semantic":[
            {
               "app":"music_kkbox",
               "input":"播放動漫歌曲類型的歌",
               "slots":[
                  {
                     "name":"keyword",
                     "value":"動漫歌曲"
                  }
               ],
               "modifier":[
                  "music_play_playlist"
               ],
               "customer":"59e031f7e4b0a8057efdce99"
            }
         ],
         "type":"music_kkbox"
      }
   ]
}
當使用者告訴 Chatbot:「播放動漫歌曲類型的歌」,由於有設定 webhook,Telegram 會把訊息傳送到 web server。程式收到訊息後,再把訊息傳給 OLAMI NLI API,就會得到上面的 Response。程式就可根據 Response 中的 type value 判斷「播放動漫歌曲類型的歌」這句話屬於 music_kkbox NLI 模組,再由 modifier 中的 element 暸解使用者的 Intent 是 music_play_playlist,最後用 slots 中的 keyword value 動漫歌曲 作為 request KKBOX Open API search method 的 query parameter,取得動漫歌曲的 playlist。
先註冊 KKBOX Developer 帳號,在 My Apps 頁面 Create new app,得到 App ID 及 Secret,再把它們填入專案目錄中的 config.ini 檔案
[KKBOX]
ID = your_app_id
SECRET = your_app_secret
接著專案目錄中,新增資料夾,名字叫做 api
$ mkdir api
進入 api 資料夾,新增兩個檔案, __init__.pykkbox.py
$ cd api
$ touch __init__.py
$ touch kkbox.py
做完上述動作後的專案目錄結構
Project Directory
├── api
|   ├── __init__.py
|   └── kkbox.py
├── nlp
|   ├── __init__.py
|   └── olami.py
├── config.ini
├── main.py
├── Pipfile
└── Pipfile.lock
新增 __init__.py 是為了讓 olami.py import api 的時候認定 api 是一個 Module。
編輯 api/__init__.py
from . import kkbox
編輯 kkbox.py
import configparser
import logging

import requests

config = configparser.ConfigParser()
config.read('config.ini')

logger = logging.getLogger(__name__)


class KKBOX:
    AUTH_URL = 'https://account.kkbox.com/oauth2/token'
    API_BASE_URL = 'https://api.kkbox.com/v1.1/'

    def __init__(self, id=config['KKBOX']['ID'], secret=config['KKBOX']['SECRET']):
        self.id = id
        self.secret = secret
        self.token = self._get_token()

    def _get_token(self):
        response = requests.post(self.AUTH_URL, data={'grant_type': 'client_credentials'}, auth=(self.id, self.secret))
        response.raise_for_status()
        return response.json()['access_token']

    def search(self, type, q, territory='TW'):
        response = requests.get(self.API_BASE_URL + 'search', params={'type': type, 'q': q, 'territory': territory},
                                headers={'Authorization': 'Bearer ' + self.token})
        response.raise_for_status()
        response_json = response.json()
        result = {
            'artist': lambda: response_json['artists']['data'][0]['url'],
            'album': lambda: response_json['albums']['data'][0]['url'],
            'track': lambda: response_json['tracks']['data'][0]['url'],
            'playlist': lambda: response_json['playlists']['data'][0]['url']
        }[type]()
        return result
kkbox.py 實作了 KKBOX class init 時會利用 App ID 及 App Secret 走 Basic Authentication 取得 access_token。還有實作 request Search API method,根據期望的 typeq(keyword) 搜尋音樂資料,當搜尋有結果時,會 return 第一筆資料的 url。
KKBOX Open API access token 取得方法可參考官方 Tutorial
Search API 詳細說明文件
完成後,編輯 nlp/olami.py,我們要讓程式可以處理新的 music_kkbox Intent
+from api.kkbox import KKBOX

class Olami:
    def intent_detection(self, nli_obj):
+       def handle_music_kkbox_type(semantic):
+           type = semantic['modifier'][0].split('_')[2]
+           slots = semantic['slots']
+           kkbox = KKBOX()
+
+           def get_slot_value(key):
+               return next(filter(lambda el: el['name'] == key, slots))['value']
+
+           _reply = {
+               'artist': lambda: kkbox.search(type, get_slot_value('artist_name')),
+               'album': lambda: kkbox.search(type, get_slot_value('album_name')),
+               'track': lambda: kkbox.search(type, get_slot_value('track_name')),
+               'playlist': lambda: kkbox.search(type, get_slot_value('keyword'))
+           }[type]()
+           return _reply

        type = nli_obj['type']
        desc = nli_obj['desc_obj']
        data = nli_obj.get('data_obj', [])

        reply = {
            'kkbox': lambda: data[0]['url'] if len(data) > 0 else desc['result'],
            'baike': lambda: data[0]['description'],
            'news': lambda: data[0]['detail'],
            'joke': lambda: data[0]['content'],
            'cooking': lambda: data[0]['content'],
            'selection': lambda: handle_selection_type(desc['type']),
            'ds': lambda: desc['result'] + '\n請用 /help 指令看看我能怎麼幫助您',
+           'music_kkbox': lambda: handle_music_kkbox_type(nli_obj['semantic'][0])
        }.get(type, lambda: desc['result'])()

        return reply
修改後的完整 olami.py
import configparser
import json
import logging
import time
from hashlib import md5
from api.kkbox import KKBOX

import requests

config = configparser.ConfigParser()
config.read('config.ini')

logger = logging.getLogger(__name__)


class NliStatusError(Exception):
    """The NLI result status is not 'ok'"""


class Olami:
    URL = 'https://tw.olami.ai/cloudservice/api'

    def __init__(self, app_key=config['OLAMI']['APP_KEY'], app_secret=config['OLAMI']['APP_SECRET'], input_type=1):
        self.app_key = app_key
        self.app_secret = app_secret
        self.input_type = input_type

    def nli(self, text, cusid=None):
        response = requests.post(self.URL, params=self._gen_parameters('nli', text, cusid))
        response.raise_for_status()
        response_json = response.json()
        if response_json['status'] != 'ok':
            raise NliStatusError(
                "NLI responded status != 'ok': {}".format(response_json['status']))
        else:
            nli_obj = response_json['data']['nli'][0]
            return self.intent_detection(nli_obj)

    def _gen_parameters(self, api, text, cusid):
        timestamp_ms = (int(time.time() * 1000))
        params = {'appkey': self.app_key,
                  'api': api,
                  'timestamp': timestamp_ms,
                  'sign': self._gen_sign(api, timestamp_ms),
                  'rq': self._gen_rq(text)}
        if cusid is not None:
            params.update(cusid=cusid)
        return params

    def _gen_sign(self, api, timestamp_ms):
        data = self.app_secret + 'api=' + api + 'appkey=' + self.app_key + \
               'timestamp=' + str(timestamp_ms) + self.app_secret
        return md5(data.encode('ascii')).hexdigest()

    def _gen_rq(self, text):
        obj = {'data_type': 'stt', 'data': {'input_type': self.input_type, 'text': text}}
        return json.dumps(obj)

    def intent_detection(self, nli_obj):
        def handle_selection_type(type):
            reply = {
                'news': lambda: desc['result'] + '\n\n' + '\n'.join(
                    str(index + 1) + '. ' + el['title'] for index, el in enumerate(data)),
                'poem': lambda: desc['result'] + '\n\n' + '\n'.join(
                    str(index + 1) + '. ' + el['poem_name'] + ',作者:' + el['author'] for index, el in
                    enumerate(data)),
                'cooking': lambda: desc['result'] + '\n\n' + '\n'.join(
                    str(index + 1) + '. ' + el['name'] for index, el in
                    enumerate(data))
            }.get(type, lambda: '對不起,你說的我還不懂,能換個說法嗎?')()
            return reply

        def handle_music_kkbox_type(semantic):
            type = semantic['modifier'][0].split('_')[2]
            slots = semantic['slots']
            kkbox = KKBOX()

            def get_slot_value(key):
                return next(filter(lambda el: el['name'] == key, slots))['value']

            _reply = {
                'artist': lambda: kkbox.search(type, get_slot_value('artist_name')),
                'album': lambda: kkbox.search(type, get_slot_value('album_name')),
                'track': lambda: kkbox.search(type, get_slot_value('track_name')),
                'playlist': lambda: kkbox.search(type, get_slot_value('keyword'))
            }[type]()
            return _reply

        type = nli_obj['type']
        desc = nli_obj['desc_obj']
        data = nli_obj.get('data_obj', [])

        reply = {
            'kkbox': lambda: data[0]['url'] if len(data) > 0 else desc['result'],
            'baike': lambda: data[0]['description'],
            'news': lambda: data[0]['detail'],
            'joke': lambda: data[0]['content'],
            'cooking': lambda: data[0]['content'],
            'selection': lambda: handle_selection_type(desc['type']),
            'ds': lambda: desc['result'] + '\n請用 /help 指令看看我能怎麼幫助您',
            'music_kkbox': lambda: handle_music_kkbox_type(nli_obj['semantic'][0])
        }.get(type, lambda: desc['result'])()

        return reply
測試 Telegram Bot

Yeah~ Chatbot 有新的 Music-KKBOX 技能了!如果手機有裝 KKBOX App,從手機點選連結就會啟動 KKBOX App 播放歌曲。

Step 8. User-friendly chatbot design

還有幾個讓 Chatbot 變得更佳 user-friendly 的方法,分享給大家。
  1. Welcome message
在使用者一加入 Chatbot 時出現,馬上讓使用者暸解 Chatbot 提供的功能,就像 Telegram 的 BotFather ㄧ樣。

  1. Reply keyboard markup
對於制式回答,提供 keyboard markup 讓使用者可以直接點選,不用自己打字。

  1. Help message
在使用者傳送的訊息 Chatbot 無法理解時出現,提示使用者這個 Chatbot 的使用方法。

  1. Error handling
錯誤發生時的處理,Chatbot 如果發生錯誤而沒有回應,使用者就會覺得很困惑。

實作,編輯 main.py
+from telegram import ReplyKeyboardMarkup
+from telegram.ext import Dispatcher, CommandHandler, MessageHandler, Filters
+
+welcome_message = '親愛的主人,您可以問我\n' \
+                  '天氣,例如:「高雄天氣如何」\n' \
+                  '百科,例如:「川普是誰」\n' \
+                  '新聞,例如:「今日新聞」\n' \
+                  '音樂,例如:「我想聽周杰倫的等你下課」\n' \
+                  '日曆,例如:「現在時間」\n' \
+                  '詩詞,例如:「我想聽水調歌頭這首詩」\n' \
+                  '笑話,例如:「講個笑話」\n' \
+                  '故事,例如:「說個故事」\n' \
+                  '股票,例如:「台積電的股價」\n' \
+                  '食譜,例如:「蛋炒飯怎麼做」\n' \
+                  '聊天,例如:「你好嗎」'
+reply_keyboard_markup = ReplyKeyboardMarkup([['高雄天氣如何'],
+                                             ['川普是誰'],
+                                             ['今日新聞'],
+                                             ['我想聽周杰倫的等你下課'],
+                                             ['現在時間'],
+                                             ['我想聽水調歌頭這首詩'],
+                                             ['講個笑話'],
+                                             ['說個故事'],
+                                             ['台積電的股價'],
+                                             ['蛋炒飯怎麼做'],
+                                             ['你好嗎']])


+def start_handler(bot, update):
+    """Send a message when the command /start is issued."""
+    update.message.reply_text(welcome_message, reply_markup=reply_keyboard_markup)
+
+
+def help_handler(bot, update):
+    """Send a message when the command /help is issued."""
+    update.message.reply_text(welcome_message, reply_markup=reply_keyboard_markup)
+
+
+def error_handler(bot, update, error):
+    """Log Errors caused by Updates."""
+    logger.error('Update "%s" caused error "%s"', update, error)
+    update.message.reply_text('對不起主人,我需要多一點時間來處理 Q_Q')

+dispatcher.add_handler(CommandHandler('start', start_handler))
+dispatcher.add_handler(CommandHandler('help', help_handler))
+dispatcher.add_error_handler(error_handler)
修改後的完整 main.py
import configparser
import logging

import telegram
from flask import Flask, request
from telegram import ReplyKeyboardMarkup
from telegram.ext import Dispatcher, CommandHandler, MessageHandler, Filters

from nlp.olami import Olami

# Load data from config.ini file
config = configparser.ConfigParser()
config.read('config.ini')

# Enable logging
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
                    level=logging.INFO)
logger = logging.getLogger(__name__)

# Initial Flask app
app = Flask(__name__)

# Initial bot by Telegram access token
bot = telegram.Bot(token=(config['TELEGRAM']['ACCESS_TOKEN']))

welcome_message = '親愛的主人,您可以問我\n' \
                  '天氣,例如:「高雄天氣如何」\n' \
                  '百科,例如:「川普是誰」\n' \
                  '新聞,例如:「今日新聞」\n' \
                  '音樂,例如:「我想聽周杰倫的等你下課」\n' \
                  '日曆,例如:「現在時間」\n' \
                  '詩詞,例如:「我想聽水調歌頭這首詩」\n' \
                  '笑話,例如:「講個笑話」\n' \
                  '故事,例如:「說個故事」\n' \
                  '股票,例如:「台積電的股價」\n' \
                  '食譜,例如:「蛋炒飯怎麼做」\n' \
                  '聊天,例如:「你好嗎」'
reply_keyboard_markup = ReplyKeyboardMarkup([['高雄天氣如何'],
                                             ['川普是誰'],
                                             ['今日新聞'],
                                             ['我想聽周杰倫的等你下課'],
                                             ['現在時間'],
                                             ['我想聽水調歌頭這首詩'],
                                             ['講個笑話'],
                                             ['說個故事'],
                                             ['台積電的股價'],
                                             ['蛋炒飯怎麼做'],
                                             ['你好嗎']])


@app.route('/hook', methods=['POST'])
def webhook_handler():
    """Set route /hook with POST method will trigger this method."""
    if request.method == "POST":
        update = telegram.Update.de_json(request.get_json(force=True), bot)
        dispatcher.process_update(update)
    return 'ok'


def start_handler(bot, update):
    """Send a message when the command /start is issued."""
    update.message.reply_text(welcome_message, reply_markup=reply_keyboard_markup)


def help_handler(bot, update):
    """Send a message when the command /help is issued."""
    update.message.reply_text(welcome_message, reply_markup=reply_keyboard_markup)


def reply_handler(bot, update):
    """Reply message."""
    text = update.message.text
    reply = Olami().nli(text)
    update.message.reply_text(reply)


def error_handler(bot, update, error):
    """Log Errors caused by Updates."""
    logger.error('Update "%s" caused error "%s"', update, error)
    update.message.reply_text('對不起主人,我需要多一點時間來處理 Q_Q')


# New a dispatcher for bot
dispatcher = Dispatcher(bot, None)

# Add handler for handling message, there are many kinds of message. For this handler, it particular handle text
# message.
dispatcher.add_handler(MessageHandler(Filters.text, reply_handler))
dispatcher.add_handler(CommandHandler('start', start_handler))
dispatcher.add_handler(CommandHandler('help', help_handler))
dispatcher.add_error_handler(error_handler)

if __name__ == "__main__":
    # Running server
    app.run(debug=True)

Last Step - Deployment

最後,我們要把 Chatbot web server 程式 deploy 到 production 環境上。選擇的是 Heroku,它的 Free pricing 方案不用綁定信用卡就可以使用,部署方法也相當簡單。確認已經有註冊 Heroku Account,而且電腦有安裝 Heroku CLI
進入 Heroku Dashboard,Create new app
回到專案目錄,新增 Procfile 檔,編輯成如下
web: gunicorn main:app --log-file -
Procfile 中的指令在程式成功 deploy 上 Heroku 後就會被執行。裡面的指令代表要讓 Heroku running 一個 web process,用 gunicorn 部署 main module 中的 Flask App。
Procfile 說明文件
Gunicorn 官網
完成後的專案目錄結構
Project Directory
├── api
|   ├── __init__.py
|   └── kkbox.py
├── nlp
|   ├── __init__.py
|   └── olami.py
├── config.ini
├── main.py
├── Pipfile
├── Pipfile.lock
└── Procfile
在專案目錄初始化 git repository
$ git init
新增 production 分支、切換到該分支
$ git checkout -b production
把專案中的所有檔案加入至 git
$ git add .
commit
$ git commit -m "Deploying to Heroku"
Log in Heroku account
$ heroku login
Add remote Heroku repository
$ heroku git:remote -a {your_heroku_app_name}
Push to Heroku repository
$ git push heroku production:master
順利的話,程式就會成功部署上 Heroku。從 console 的輸出訊息、Heroku Dashboard App 的 Settings 頁面中,及右上角 Open app 都可以找到 App 的 url。它會是
https://{$your_heroku_app_name}.herokuapp.com/
例如:
https://kkbox-telegram-bot.herokuapp.com/
最後將這個 url 設定為 Telegram Bot 的 webhook url,就大功告成了。
https://api.telegram.org/bot{$token}/setWebhook?url={$webhook_url}
$token$webhook_url 請換成你在 Step 1 中申請到的,例如:
https://api.telegram.org/bot606248605:AAGv_TOJdNNMc_v3toHK_X6M-dev_1tG-JA/setWebhook?url=https://kkbox-telegram-bot.herokuapp.com/hook
Getting Started on Heroku with Python Document
如果要將專案 push 到 GitHub,先 checkout 回 master branch、新增 .gitignore 檔案,ignore config.ini(因為裡面有 credential 資訊),再進行後續動作

總結


下兩篇文章介紹如何打造具備 NLP 功能的 Telegram Bot,共 9 個步驟實作上圖 Chatbot 流程中的每個環節,包含導入 NLP service、Intent detection、Add custom skill、優化使用者體驗等。
完整程式碼放在 GitHub repository,按照 README 的步驟,就可以 deploy 和範例具備一樣技能的 Telegram Bot,歡迎 pull request。
回到第一篇文章

留言

這個網誌中的熱門文章

會眾新服務「療癒市集」結合紅酒瑜伽、冥想正念、按摩小站、氮氣咖啡 | Introducing the Healing Market with Yoga Wine, Meditations, Massage Station, Nitro Coffee

新 [English version below] 今年的 COSCUP x KCD 2022 Taiwan 嘗試推出新的會眾服務,希望在繁忙的平日還抽空在假日來參與活動時、能夠療癒一下心靈與身體的負擔,「 療癒市集 」希望能夠為你帶來不一樣的體驗! 由於部分課程需要 預先報名 ,如果你有意參與課程,請直接 寄信報名 ,並等候志工收件處理,感謝! 以下是相關的課程簡介。 紅酒瑜伽 照片來源:台南安平雅樂軒酒店 都市生活步調快,上班壓力大,周末總想找些紓壓的活動幫自己充飽電,用更好的狀態去迎接下個挑戰。而說到現在最新穎,時尚的選擇那就不能不提風靡歐美的「紅酒瑜珈」。現在不用出國,在 COSCUP 也可以體驗這種身心靈保養的運動。 課程中,老師也會指引學員在不同階段品嘗手中的葡萄酒,感受這支紅酒在不同醒酒階段的各種風味,細細品嚐它的層次與韻味。酒精也同時能夠增加血液循環,讓身體發熱,達到肌肉暖身,類似熱瑜珈的運動效果! 紅酒瑜珈是什麼? 於 2017 年誕生於紐約,紅酒瑜珈是把紅酒帶進瑜珈練習的一種課程。在瑜珈練習的過程中,學員們手上各有一杯紅酒。老師帶領著學員練習瑜珈姿勢,並加入酒杯動作來增加難度與運動量。比如說,手握紅酒杯進行戰士三式(Warrior III),為了不讓液體撒出來,其實比起沒有道具輔助的瑜珈需要多一點肌耐力,所以可以達到更大的脂肪燃燒跟運動效果。 課程須知 每一課程時長為一個小時,費用 $470元/人,每一堂最多 12 人。 講座內容包含:活動包含約 45 分鐘的紅酒瑜珈活動,及約 15 分鐘的講解,課程會提供酒杯,若損壞葡萄酒杯,則每只費用 $250。 需自備:瑜伽墊、水壺、毛巾等個人用品。 ★ 由於課程需要預先報名,如果你有意參與此課程,請參閱 課程時間表 並直接 寄信報名 、等候志工收件處理,感謝! 冥想正念 我們都渴望獲得內心的平靜,尤其在現在這個快速、忙碌更迭的時代,在這個無時無刻都在面對比較、落後焦慮的世代。透過冥想與正念,你會更加理解你自己,你也會更加理解你的周遭一切,而點滴的時光之間,再次放下自我,又擁抱自我。 課程須知 每一課程時長為一個小時,費用 $350元/人,每一堂最多 10 人(未滿 5 人不開課)。 講座內容包含:冥想正念概念介紹、正念心理...

「智慧政府」該如何成功向政府申請資料?

  如何寫出精確的『資料申請』需求? 前往使用資料申請產生器: https://dataopener.tw/application 資料開放的狀況有哪些? 「尚」未開放:沒有資料、產製中或可能會產製 「尚」未開放:資訊公開但非開放資料,或需要申請 部分開放:值得再開放或更開放的資料 已開放:Open by default 階段,將既有資料轉成開放資料 不開放:最後無法開放的資料 常見不同意開放資料的理由? 沒有該資料(尚未收集或彙整) 沒有該資料(不存在該類別) 尚未蒐集完整 申請者所提出的需求資料並無明確定義 擔心資料品質 涉及個資 涉及營業秘密 屬於秘密類文件 非資料所有機關 資料在委辦單位 屬地方自治事務 要收費 擔心外界誤用、誤判 資料僅供內部參考 開放後將妨礙行政 依檔案法為維護公共利益不公開 非權責明定應提供之資料 要 A 給 B 已提供單筆查詢功能 僅能提供統計資料故無法呈現細項 當前的演算技術無法達成該資料需求內容 無開放格式 採用說明文件較為適合 建置中 硬體維護成本太高 視預算經費狀況再行評估 還要研議 開放資料產製過程中的困難? 一起來跟政府申請資料吧! 專注我們 GitHub:  https://github.com/opengovdatatw Facebook:  https://www.facebook.com/2125769830846436/

OSCVPass 使用說明,VIP 室與氮氣咖啡 / OSCVPass, VIP Room and nitro cold brew(NCB) coffee

贈 如果在 2020–07-29 前通過 OSCVPass 方案,將可在 COSCUP 活動日使用 VIP 室與每日一杯 氮氣咖啡 或氮氣紅茶。 "Nitro cold brew" by T.Tseng is licensed under CC BY 2.0 示意圖僅供參考 / Picture for reference only 詳細兌換方式 申請 OSCVPass ,並在 2020-07-29 前通過。 收到 COSCUP 寄送的 認證碼 信件。 (2020-07-30 前寄送) 活動二日到「 志工服務台 」驗證、領取兌換券。 其他注意事項 「志工服務台」的位置在 TR2樓左轉角落,與「大會服務台」同位置。 VIP 室位於 TR515、氮氣咖啡攤位在 TR1樓廣場。 位在 TR1樓廣場還有其他小餐車,也歡迎捧場! 近期活動訊息 個人贊助 (34%) : 活動詳情 遊樂園預約服務: 活動詳情 前夜派對: 活動詳情 If you passed the OSCVPass project before 2020-07-29. On COSCUP 2020 two days, you can show the token-code letter to exchange the ticket for VIP room and one cup of nitro cold brew(NCB) coffee or tea per day on the volunteer service desk. How-to Submit application to OSCVPass and approved before 2020-07-29. Receive the token-code letter from COSCUP. (will send by 2020-07-30) Take the token code to exchange the tickets for the VIP room and one cup of nitro cold brew(NCB) coffee or tea per day on the volunteer...