💌

[macOS・Accessibility API] 日本語入力のためのSlackメッセージ取得アプリ

2024/06/19に公開

こんにちは。この前に日本語入力の開発プロジェクトで未踏ITに採択されましたNaoki|電電猫猫です。今回はSlackのメッセージを取得してprintするというシンプルなアプリを作成しました。

究極の日本語入力システム

今回未踏ITに採択されたプロジェクトは「ニューラル言語モデルによる個人最適な日本語入力システムの開発」になります。簡潔に述べると、ニューラル言語モデルにユーザが読んだ情報や受け取ったメッセージを与えておくことで、状況に応じた最適な変換を達成しようというものです。これによって究極の日本語入力システムの実現を目指します。

ユーザが文章を入力するとき、今現在その入力をしようと思うに至った「ユーザの文脈」とも言える情報があります。

ユーザーの文脈を日本語入力システムに提供することで、最適な変換が可能になります。例えば、Slackで返信しようとする際に、返信相手のメッセージ内容を理解して日本語入力システムが変換を行うことで、次の画像のように適切な変換が可能になるでしょう。

また、理想的には、ユーザーのプロフィールに「好きな作家がモンゴメリ」という情報を入力するだけで、このような変換が行われるようになります。この好きな作家の情報のように、「ユーザの文脈」にはそのユーザの職業や年齢といったプロフィール、さらには今まで読んだ記事や本、見てきた動画など非常に多岐にわたる情報を含めることができます。私たちの開発する日本語入力システムではユーザの設定した範囲で情報を取得し、ユーザの文脈として取り扱います。

しかし、Slackは日本語入力システムとは独立のアプリケーションであり、こういった文脈にアクセスすることは素直には難しいです。そこで、このような「ユーザの文脈」をよく理解した日本語入力システムを開発するため、macOSのアクセシビリティAPIを用いてSlackのメッセージを取得するアプリを作りました。

https://github.com/nyanko3141592/AXUIElementInspector-forPrototype

Zenzai

「Zenzai」は、ニューラル言語モデルを用いた新しいかな漢字変換システムです。かな漢字変換に特化したGPT-2「zenz-v1」を利用してかな漢字変換を行います。ニューラル言語モデルを使うことによってより文脈を考慮したかな漢字変換が可能になります。

https://zenn.dev/azookey/articles/ea15bacf81521e

このシステムは「AzooKeyKanaKanjiConverter」というazooKeyのベースとなるかな漢字変換システムをもとに構築されており、従来の変換手法とニューラル言語モデルによる文章生成を組み合わせることで高速で高精度なかな漢字変換を提供しています。

今回のアプリで取得したユーザの文脈をazooKeyのZenzaiに情報として与えることで真に文脈を踏まえた日本語入力システムの構築が可能になります。取得したユーザの文脈を日本語入力システムと共有するためにはIMKitにUI情報の取得機能を追加することや、今回のアプリを独立なアプリとして扱い、アプリ間で通信を行う方法などが考えられます。

私たちのプロジェクトではこのユーザ文脈を取得するシステムとそれを蓄積するデータベースを構築し、Zenzaiと連携します。データはユーザの許した範囲でのみ取得します。今回のアプリでは文脈取得システムとしてのプロトタイプとして開発しました。

アクセシビリティAPIについて

今回のアプリのメイントピックはアクセシビリティAPIです。macOSには、音声認識コマンド、画面上のキーボード、補助装置、およびポインタを制御するその他の代替方法を使用してMac内を移動したり、Macを操作したりできるアクセシビリティ機能が含まれています。今回のプロジェクトでは、これらの機能をアクセシビリティAPIを通じて利用し、UI情報の取得機能を活用してSlackのメッセージを取得します。

今回開発したアプリをベースに、Slackだけでなく他のメッセージアプリでも受信メッセージを取得することが可能です。これにより、Slackに限らず、さまざまなユーザーの文脈を日本語入力システムが把握できるようになります。

コード詳細

1. アプリケーション起動時の設定

アプリケーション起動時にアクセシビリティ権限の確認と設定を行います。権限が許可されていない場合は、権限が許可されるまで待機します。

func applicationDidFinishLaunching(_ aNotification: Notification) {
    let trustedCheckOptionPrompt = kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString
    let options = [trustedCheckOptionPrompt: true] as CFDictionary
    if AXIsProcessTrustedWithOptions(options) {
        setup()
    } else {
        waitPermissionGranted {
            self.setup()
        }
    }
    NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(activeAppDidChange(_:)), name: NSWorkspace.didActivateApplicationNotification, object: nil)
}

2. アクティブアプリケーションの変更監視

アクティブなアプリケーションが変更されたときに呼び出されるメソッドを実装します。Slack がアクティブになった場合、メッセージ取得を開始します。

@objc func activeAppDidChange(_ notification: Notification) {
    if let activeApp = NSWorkspace.shared.frontmostApplication {
        activeApplicationName = activeApp.localizedName!
        print("Active app: \(activeApplicationName)")
        if activeApplicationName == targetAppName {
            if let slackApp = slackApp {
                fetchSlackMessages(from: slackApp)
            }
        }
    }
}

3. Slack アプリケーションの特定と初期設定

Slack アプリケーションの AXUIElement オブジェクトを取得し、監視を開始する準備を行います。

private func setup() {
    if let slackApp = getSlackApplication() {
        self.slackApp = slackApp
        startMonitoringSlack()
    } else {
        print("Slack is not running.")
    }
}

private func getSlackApplication() -> AXUIElement? {
    let workspace = NSWorkspace.shared
    let apps = workspace.runningApplications
    for app in apps {
        if app.localizedName == targetAppName {
            return AXUIElementCreateApplication(app.processIdentifier)
        }
    }
    return nil
}

4. メッセージ取得機能

Slack のウィンドウからメッセージを抽出し、コンソールに出力します。

private func fetchSlackMessages(from app: AXUIElement) {
    print("Attempting to fetch Slack messages...")
    var value: AnyObject?
    let result = AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &value)
    if result == .success, let windows = value as? [AXUIElement] {
        for window in windows {
            extractMessagesFromWindow(window)
        }
    } else {
        print("Could not retrieve Slack windows.")
    }
}

private func extractMessagesFromWindow(_ window: AXUIElement) {
    var value: AnyObject?
    let result = AXUIElementCopyAttributeValue(window, kAXChildrenAttribute as CFString, &value)
    if result == .success, let children = value as? [AXUIElement] {
        for child in children {
            extractMessagesFromElement(child)
        }
    } else {
        print("Could not retrieve window children.")
    }
}

private func extractMessagesFromElement(_ element: AXUIElement) {
    var value: AnyObject?
    let result = AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &value)
    if result == .success, let role = value as? String {
        if role == kAXStaticTextRole as String {
            var messageValue: AnyObject?
            let messageResult = AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &messageValue)
            if messageResult == .success, let message = messageValue as? String {
                print("Message: \(message)")
            }
        } else {
            var childValue: AnyObject?
            let childResult = AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childValue)
            if childResult == .success, let children = childValue as? [AXUIElement] {
                for child in children {
                    extractMessagesFromElement(child)
                }
            }
        }
    }
}

5. アクセシビリティイベントのハンドリング

AXObserver を使用して、Slack のメッセージ変更イベントを監視し、イベント発生時にメッセージを再取得します。

private func startMonitoringSlack() {
    guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.localizedName == targetAppName }) else {
        print("\(self.targetAppName)is not running.")
        return
    }

    var observer: AXObserver?
    AXObserverCreate(app.processIdentifier, { (observer, element, notification, refcon) in
        let delegate = Unmanaged<AppDelegate>.fromOpaque(refcon!).takeUnretainedValue()
        delegate.handleAXEvent(element: element, notification: notification as String)
    }, &observer)

    if let observer = observer {
        AXObserverAddNotification(observer, slackApp, kAXValueChangedNotification as CFString, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
        CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode)
        self.observer = observer
    }
}

private func handleAXEvent(element: AXUIElement, notification: String) {
    if notification == kAXValueChangedNotification as String {
        fetchSlackMessages(from: slackApp)
    }
}

参考にさせていただいたコードはKishikawaKatsumi様のtry! Swiftのコードです
https://github.com/kishikawakatsumi/tryswift2024

https://blog.kishikawakatsumi.com/entry/2024/03/28/113452

ほかのアプリのメッセージ取得

Slack以外のアプリでメッセージを取得する場合も同様に、AXUIElementを使用して対象アプリケーションのUI要素にアクセスし、必要な情報を取得します。

UI要素の指定

Xcode -> Open Developer Tool -> Accessibility Inspectorを起動

次のようにInspectorから取得したUIの部分を選択するとHierarchyを取得することができます。

このHierarchyに準じて取得するコードを書いていきます。今回でいえば一旦全ての情報を取得するために子要素を再帰的に取得しています。HierarchyではHTMLのようにUIのオブジェクトの階層構造が確認できます。

実際に見てみるとUI要素は 画面全体 > メッセージの表示領域 > メッセージ1つのgroup(テキストや送信者のアイコンなどを含む) > メッセージテキスト のように階層構造をなしています。

発展

取得したSlackのメッセージを活用し、特定のユーザのメッセージ履歴をもとに、個別に最適化された日本語入力システムを開発することも可能です。取得先のデータも区別することが可能なため、特定のSlackチャンネルやワークスペースではデータの保存をしないといったことも容易に可能です。自分の読んだ記事から答えてくれるRAGを構築するのも夢がありますね。

英語を入力しようとしたけどローマ字モードのまま入力してしまったというような経験ありますよね。これも日本語入力システムが状況を判断し、入力したい言語が英語であることを推測して英字入力に自動で切り替える、といったことも可能になります。具体的には返信しようとしているメッセージが英語であれば自動で入力モードを切り替えるというような方法が考えられます。

また今年のWWDCで発表された翻訳APIと連携し、返信相手が英語でメッセージを送ろうとしていたら自動で翻訳するなど面白い使い方が無限に思いつきます。

まとめ

このアプリは、macOSのアクセシビリティAPIを活用してSlackメッセージを取得するシンプルな実装ですが、将来的には多くの応用が期待できます。ユーザの文脈を理解した日本語入力システムの開発に向けて、一歩踏み出した形となりました。

参考文献

azooKey blogs

Discussion