[[🦉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)