[[🦉Fenice]]をつくってみたときの記録。 ## プロジェクト作成 ```console bun x wxt@latest init fenice cd fenice ``` 依存関係インストール。 ```console $ bun i bun install v1.0.35 (940448d6) + @vitejs/[email protected] + [email protected] + [email protected] + [email protected] + [email protected] 475 packages installed [70.34s] Blocked 1 postinstall. Run `bun pm untrusted` for details. $ bun run postinstall ``` ```console bun dev ``` とりあえず動作すればOK。 ## 名称とアイコンの設定 たまには見た目部分から仕上げていく。まずは名前とアイコン。 - `package.json`の`name`を`fenice`に変更 - `public/icon/384.png` を作成 作成したロゴ。 ![[fenice.png]] ## Vuetifyのインストール [[Vuetify3]]を使う。 ```console bun add vuetify ``` `entrypoints/popup/main.ts`の内容を変更する。 `before` ```ts import { createApp } from 'vue'; import './style.css'; import App from './App.vue'; createApp(App).mount('#app'); ``` `after` ```ts import { createApp } from "vue"; import "./style.css"; import App from "./App.vue"; // Vuetify import "vuetify/styles"; import { createVuetify } from "vuetify"; import * as components from "vuetify/components"; import * as directives from "vuetify/directives"; const vuetify = createVuetify({ components, directives, }); createApp(App).use(vuetify).mount("#app"); ``` `entrypoints/popup/App.vue`をいじって動作確認する。 ```html <template> <v-container> <v-btn color="primary">hoge</v-btn> </v-container> </template> ``` 大丈夫そう。 ![[Pasted image 20240328154906.png]] ## トップページの作成 ポップアップの中にリンクを作成し、クリックしたら`top.html`に飛ばす。 - [x] トップページの作成 - [x] ポップアップページの作成 `endpoints/popup/index.html`は以下のような感じ。 ```html <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Default Popup Title</title> <meta name="manifest.type" content="browser_action" /> </head> <body> <button style="width: 120px"> <a href="./top.html" target="_blank" style="text-decoration: none" >トップへ</a > </button> </body> </html> ``` `トップへ`ボタンを押すと新しいタブで[[Vuetify3]]ベースのページが表示される。それらは `endpoints/top/`配下にある。 ## OAuth 2.0を使って認証 以下を参考にし、[[Google People API]]を[[Slack API]]に置き換えて進める。 <div class="link-card"> <div class="link-card-header"> <img src="https://www.gstatic.com/devrel-devsite/prod/vdd667e8703bf3eb3250e728f4f199ed0baf72dd7a2c58290f8fa685e2652be47/chrome/images/favicon.png" class="link-card-site-icon"/> <span class="link-card-site-name">Chrome for Developers</span> </div> <div class="link-card-body"> <div class="link-card-content"> <p class="link-card-title">OAuth 2.0: Google Cloud でユーザーを認証  |  Extensions  |  Chrome for Developers</p> <p class="link-card-description">Google People API、Chrome Identity API、OAuth2 を介してユーザーの Google コンタクトにアクセスする拡張機能を作成する詳細な手順。</p> </div> </div> <a href="https://developer.chrome.com/docs/extensions/how-to/integrate/oauth?hl=ja"></a> </div> ### 拡張機能IDを固定する [[ZIP]]として圧縮する。ただし、`package.json`のバージョンを`0.0.0`以外にしておくこと。 ```console bun run zip ``` [[デベロッパーダッシュボード]]で `新しいアイテムを追加` をクリックする。 ![[Pasted image 20240328181227.png]] [[ZIP]]圧縮したものを指定する。アップロードに成功したら公開鍵を確認する。 ![[Pasted image 20240328181622.png]] ``` MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzBUsz9KlGfCQ9DlG/Z+KsRTA2D8Dw/BR9Svz/KmMLe8+8ps2PY0nZrN2U2MLhDJ5wnJk7NfLIihUVc8MRijypg5rx5xnZMhZxpeQbTrvvAexFaTOu9TMr52M91lMBuRttBE2L70RzD0lXmDXRv0wLGhGdNzRpE4l3oEZq2BZjBUhduL7Z3jp+fE8kF4fKFI58HvrKs9JPbJ80Ql94cxJFcwuYq0pffnBXOCXbKYgu2PySajiSaeQsr59n8UOIBGFo+KfqLxnQ9As6tG+pDdWlqhm+tf4qcfgaIVJ3qUUhaAi3SClsK3fjneyD/QD3a58QHpYMSNcoXKd1PLMUzBkwwIDAQAB ``` 公開鍵の値を`manifest.key`に設定する。ただ、型定義には含まれてなさそうなので ts-[[@ts-expect-errorコメント]]で凌ぐ。 > [[📝WXTでwxt.config.tsでmanifest.keyを指定すると型エラーになる]] `wxt.config.ts` ```ts import { defineConfig } from "wxt"; import vue from "@vitejs/plugin-vue"; // See https://wxt.dev/api/config.html export default defineConfig({ imports: { addons: { vueTemplate: true, }, }, vite: () => ({ plugins: [vue()], }), manifest: { // @ts-expect-error: https://github.com/wxt-dev/wxt/issues/521 key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzBUsz9KlGfCQ9DlG/Z+KsRTA2D8Dw/BR9Svz/KmMLe8+8ps2PY0nZrN2U2MLhDJ5wnJk7NfLIihUVc8MRijypg5rx5xnZMhZxpeQbTrvvAexFaTOu9TMr52M91lMBuRttBE2L70RzD0lXmDXRv0wLGhGdNzRpE4l3oEZq2BZjBUhduL7Z3jp+fE8kF4fKFI58HvrKs9JPbJ80Ql94cxJFcwuYq0pffnBXOCXbKYgu2PySajiSaeQsr59n8UOIBGFo+KfqLxnQ9As6tG+pDdWlqhm+tf4qcfgaIVJ3qUUhaAi3SClsK3fjneyD/QD3a58QHpYMSNcoXKd1PLMUzBkwwIDAQAB", }, }); ``` もう一度 `bun dev` を実行し、[[パッケージ化されていない拡張機能を読み込む]]とIDが変わる。 ![[Pasted image 20240328183848.png|frame]] *ここのIDが一緒ならOK* ### OAuthのクライアントIDを作成する https://api.slack.com/apps にアクセスし、[[Slackアプリを作成]]する。 ### リダイレクトURLをセットする `OAUth & Permissions` でセットが必要。 ![[Pasted image 20240328202723.png]] 指定した値は `https://{拡張機能ID}.chromiumapp.org`。この値をセットしないと[[🦉Fenice]]のリクエストで指定した`redirect_url`を受け付けてくれない。 [[#拡張機能IDを固定する]]必要があるのはこのため。 ### manifestの設定変更 `identity`を利用できるようにする。 ```ts manifest: { key: "省略", permissions: ["identity"], }, ``` ### codeの取得 `identity.launchWebAuthFlow`を使う。 ```ts // code取得 const clientId = "クライアントID"; const redirectUri = encodeURIComponent(browser.identity.getRedirectURL()); const authUrl = `https://slack.com/oauth/v2/authorize?client_id=${clientId}&user_scope=chat:write,channels:read&redirect_uri=${redirectUri}`; const responseUrl = await browser.identity.launchWebAuthFlow({ interactive: true, url: authUrl, }); const code = new URL(responseUrl).searchParams.get("code"); console.log(code); ``` > [!attention] > スコープに関するクエリは `scope` と `user_scope` があるので注意。前者はbot、後者はuser。 > > [Sign In With Slack - Invalid scopes: identity.basic, identity.avatar](https://stackoverflow.com/questions/61150208/sign-in-with-slack-invalid-scopes-identity-basic-identity-avatar) 先のコードを実行すると以下のような[[OAuth]]っぽい画面が出る。`Allow`を押すと、コンソールに`code`が出力される。 ![[Pasted image 20240328204157.png]] ### アクセストークンの取得 [oauth.v2.access](https://api.slack.com/methods/oauth.v2.access) APIでcodeや[[クライアントID (OAuth)|クライアントID]]などからアクセストークンを取得する。 ```ts // トークン取得 const clientSecret = "3547ca9c416661dc09a951c61a32c3d8"; const res = await fetch("https://slack.com/api/oauth.v2.access", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: `client_id=${clientId}&client_secret=${clientSecret}&code=${code}&redirect_uri=${redirectUri}`, }); const { ok, authed_user: user } = await res.json(); console.log(user.access_token); ``` ## メッセージをPOST manifestで[[Slack Web API]]のホストを`host_permissions`に追加する必要がある。これをやらないと[[CORS]]でリクエストがエラーになる。 ```ts manifest: { host_permissions: ["https://slack.com/api/*"], } ``` そして実装。[chat.postMessage](https://api.slack.com/methods/chat.postMessage)を使う。 ```ts const res = await fetch("https://slack.com/api/chat.postMessage", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token.value}`, }, body: JSON.stringify({ channel: "#times_tadashi-aikawa", text: "テストです", }), }); ``` ちゃんとポストされた。 ## Auto-imports > [[Auto-importsを有効 (WXT)]] [[Nuxt3]]のような[[Auto-imports (Nuxt)|Auto-imports]]を実現する。`wxt.config.ts`の`imports.presets`に`"vue"`を追加する。 ```ts export default defineConfig({ imports: { presets: ["vue"], }, ``` 一度 `bun dev` でビルドすればOK。 ## 設定画面の追加 client_idとclient_secretは[[Slack]]のworkspaceごとに異なるので、設定で指定できるようにしたい。 > [Options – WXT](https://wxt.dev/entrypoints/options.html) [[ローカルストレージ]]を利用するために `permission` を追加する。 ```ts export default defineConfig({ manifest: { permissions: ["storage"], ``` `entrypoints/options/`を作成する。 ```html <script setup lang="ts"> interface State { testValue: string; } const state = reactive<State>({ testValue: "", }); onBeforeMount(async () => { const testValue = await storage.getItem<string>("local:testValue"); if (!testValue) { return; } state.testValue = testValue; }); const handleClickSave = async () => { debugger; await storage.setItem<string>("local:testValue", state.testValue); }; </script> <template> <v-container> <v-text-field v-model="state.testValue" /> <v-btn @click="handleClickSave">設定を保存する</v-btn> </v-container> </template> ``` とりあえず動かすだけならこれでOK。 ## トースト表示 [[vue3-toastify]]を使う。 ```console bun add vue3-toastify ``` `main.ts`に追加。 ```diff import { createApp } from "vue"; import App from "./App.vue"; + import Vue3Toastify from "vue3-toastify"; + import "vue3-toastify/dist/index.css"; // Vuetify import "vuetify/styles"; import { createVuetify } from "vuetify"; import * as components from "vuetify/components"; import * as directives from "vuetify/directives"; const vuetify = createVuetify({ components, directives, }); createApp(App) .use(vuetify) + .use(Vue3Toastify, { autoClose: 1000 }) .mount("#app"); ``` ## TODO - この[[ノート]]でやること - [x] 設定画面追加 - [x] 設定画面をstorageで管理できるようにする - [x] べた書きで動作確認 - [x] モジュールの切り出し - [x] client_idとclient_secretを設定で管理できるようにする - [x] client_idとclient_secretの設定値を使って投稿する - [-] エラーをトーストで表示する - [x] トークンをローカルストレージで管理 - [x] ページのリファクタリング - [x] 投稿内容の記載 - [x] 投稿channelの選択 - [x] channel一覧の取得 - [x] channelの選択 - [x] 通信処理の切り離し - [x] ローディング画像 - [x] ポップアップの中を格好良くする - [x] READMEの作成 - [x] リポジトリの作成 - この[[ノート]]ではやらないこと - トークンをstorageで補完してよしなに扱う - リッチテキスト対応 (編集) - リッチテキスト対応 (表示) - ナビゲーションの作成 - Zod対応 - CIの作成 - マーケットへの登録 ## 参考 - [ChromiumブラウザでもChrome拡張でGoogleログインしたい](https://zenn.dev/karabiner_inc/articles/7197d6565ec2d4) - [\[OAuth2\] invalid\_scope error on chat:write scope \| Slack Community](https://forums.slackcommunity.com/s/question/0D53a00008dG0eKCAS/oauth2-invalidscope-error-on-chatwrite-scope?language=ja)