こんにちは。20BのSysAd班の@reyuです。
この記事は新歓ブログリレー2021の16日目の記事です。そして、SysAdTechBlogの第3回目の記事です。
3といえばtraQ v3ことtraQ-Sですね。というわけで今回は部内向けのチャットサービスであるtraQについての話です。
traQについて
traQとは、traPで独自に開発・運用している部内向けのチャットサービスです。
詳細については以下の記事などに書かれています。
さて、traQは数多くの機能を搭載していますが、そのうちの1つにメンション機能があります。例えば以下のようなメッセージを送信すると...
@traP と書くとメンションになる
自分 @reyu は強調される
存在しないユーザー @hoge は置換されない
:@reyu: はユーザーアイコンのスタンプになる
```
コードブロック内で @reyu のようにメンションしても置換されない
```
また、チャンネルリンクについても同様に、いい感じに置換されます。
仕組み
メッセージ送信時の処理
メッセージ送信時には、メッセージ内からメンションやチャンネルリンクを探し、!
+ JSON文字列 の形に置換しています。
例 :
・ @reyu
→ !{"type":"user","raw":"@reyu","id":"5797d23b-ba40-4fb7-8107-69d0b2ac2da4"}
・ #gps/times/reyu
→ !{"type":"channel","raw":"#gps/times/reyu","id":"34e27bfd-1d2f-485d-8880-7a2f33dcd7ae"}
この処理はクライアントだけでなくサーバーにも実装されていて、サーバーではリクエスト内に embed=true
が存在する場合にこの置換を行います。
これによって、ユーザーが制作したBotなどから投稿されたメッセージについても同様の置換を行うことができます。
メッセージ受信時の処理
受信時には、逆にJSON文字列をパースして表示しています。
traQはMarkdown記法に対応していて、そのパースにmarkdown-itやそのプラグインを利用していることもあり、JSON文字列のパースもmarkdown-itのプラグインとして実装されています。
これには、markdown-it-jsonが利用されています。これは、パース対象を絞り込む関数 validate()
とパース方法を指定する関数 transform()
を渡すことで、!{ ... }
形式のJSON文字列をパースするmarkdown-itのプラグインを実装できるライブラリです。
validate()
はJSONをオブジェクトとして受け取り、そのJSONをパースするかどうかをbool型で返します。traQでは以下のようになっています。
export const validate = (data: Readonly<unknown>): data is ValidStructData => {
if (!isStructData(data)) {
return false
}
const { type, id } = data
return (
type === 'user' ||
(type === 'channel' && !!store.getChannel(id)) ||
type === 'group'
)
}
transform()
は state
と JSONオブジェクトを受け取り、必要に応じて state
に対して操作を行います。
traQでは以下のようになっています(長いのでユーザーメンション以外については省略しています)。
const transformUser: TransformFunc = (state, { type, id, raw }) => {
const attributes: [string, string][] = []
const me = store.getMe()
attributes.push(['href', `javascript:openUserModal('${id}')`])
if (id === me?.id) {
attributes.push(['class', 'message-user-link-highlight message-user-link'])
} else {
attributes.push(['class', 'message-user-link'])
}
let t = state.push('traq_extends_link_open', 'a', 1)
t.attrs = attributes
t.meta = { type, data: id }
t = state.push('text', '', 0)
t.content = raw
state.push('traq_extends_link_close', 'a', -1)
}
const transform: TransformFunc = (state, data) => {
if (data.type === 'user') {
transformUser(state, data)
return
}
}
state.push()
は state.tokens
にToken型のオブジェクトを追加し、追加したオブジェクトを返します(参考1 参考2)。オブジェクトは再代入されるまでは同じ参照先を持つことから、以下のコードのように変数 t
に返り値を入れ、それに対して操作をすることで state.tokens
の要素を変更することができます。
バグ対応 : アイコンスタンプとの衝突
traQでは、:@username:
と書くことでユーザーアイコンのスタンプを送信することができます。当然これがメンションになると困るのですが...
となってしまったことがありました(1年近く前の話ですが)。
なんで?
@username あああ
だけでなく @usernameあああ
なども置換するようにするための修正が原因でバグが起きていました。
この修正では、以下の順に処理を行うようにしていました。
正規表現
[@@](\S{1,32})
を用いてマッチする文字列を取得するそのユーザー(もしくはグループ)が存在していたら、置換処理を行う
存在していなかったら、上で取得した文字列に対して正規表現
^[@@]([a-zA-Z0-9_-]{1,32})
を用いてマッチする文字列を取得するそのユーザーが存在していたら、置換処理を行う
一見すると二度手間にも見えますが、グループ名に使える文字の種類がユーザー名より多いことからこのような処理となっています。
これによって上記のバグが発生していました。
:@username:ほげほげ ふがふが
→@username:ほげほげ
username:ほげほげ
というユーザー(もしくはグループ)は存在しないので、何もしない@username:ほげほげ
→@username
username
というユーザーは存在するので、置換される
対応
1つ目の正規表現を :?[@@](\S{1,32})
とすることによって、@
の前に :
が存在する場合はそこも含めてマッチするようにしました。これによって、@
の前に :
が存在するような場合を分類して除くことができます。
バグ対応 : spoiler との衝突
traQでは、markdown-it-spoilerという独自のプラグインを使用しています。例えば !!hoge!!
というメッセージを送信すると...
上でも書いたように、traQのメンションやチャンネルリンクは !
+ JSON文字列 として表されています。いかにも衝突しそうな雰囲気がしますね。
した
やっぱりしました。
!!@username!!
というメッセージを投稿する置換されて、
!!!{ ... }!!
のような文字列となる!!!
の部分が!
+!!
のように扱われてしまい、!
+ spoiler のようになってしまう
対応
markdown-it-spoilerでは、例えば !!!
のように奇数個の !
が連続してあったとき、最初の1つを先に処理してから2つずつ見るようにしていました。
if (len % 2) {
const token = state.push("text", "", 0)
token.content = ch // ch == '!'
len--
}
for (let i = 0; i < len; i += 2) {
// ...
修正によって、frontPriorMode=true
のときには前から2つずつ見ていき、奇数個の時は最後の !
をパースに含めないようになりました。
frontPriorMode でないときにのみ最初の
!
を先に処理する
let isOdd = false
if (len % 2) {
isOdd = true
if (!frontPriorMode) {
const token = state.push("text", "", 0)
token.content = ch
}
len--
}
for (let i = 0; i < len; i += 2) {
// ...
frontPriorMode のときは最後の
!
をパース対象に含めないようにする
state.pos += scanned.length
if (isOdd && frontPriorMode) {
state.pos--
}
おわり
明日の担当者は @Ras です。お楽しみに!