余ったAndroid端末でTwitterBotを作る(WorkManager + AccessibilityService)

投稿日: 2025-07-27

一年ほど前にAndroid端末のみで動くTwitterのBotを作ったのでその時の方法をまとめます。
この方法ではWorkManagerとAccessibilityServiceを使って端末を自動で操作してツイートを行うためTwitterAPIなどは不要です。

実際に作成したBot(すでに停止済み)
https://x.com/oisinbot2024

この方法でアカウントがBANされるようなことはありませんでしたが、あくまで自己責任でお願いします。
またWorkManager、AccessibilityService等の詳しい使い方は公式のリファレンスを見てください。


動作の流れ

1. WorkManagerのPeriodicWorkRequestを使い、1時間に一度通知を表示させる。
2. AccessibilityServiceで通知の表示を検知して、Twitterアプリを起動する。
3. 再びAccessibilityServiceでTwitterアプリの起動を検知&ツイートボタンをタップ


1. WorkManagerのPeriodicWorkRequestを使い、1時間に一度通知を表示させる。

PeriodicWorkRequestに設定できる最小の動作間隔は15分です。そのため、ツイートは15分に一回になります。
これよりも短い間隔で動作させたい場合は他の方法を考える必要があります。

  fun scheduleTweetWork() {
      val manager = WorkManager.getInstance(this)
      manager.cancelAllWorkByTag(TweetWorker.TAG)
      val request = PeriodicWorkRequest.Builder(TweetWorker::class.java, 60, TimeUnit.MINUTES)
                                       .addTag(TweetWorker.TAG).build()
      manager.enqueue(request)
  }
class TweetWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
  companion object {
      const val TAG = "TweetWorker"
  }
  override fun doWork(): Result {
      val notificationManager: NotificationManager =
          applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager


      val id = "channel_id"
      val name = "通知"
      val importance = NotificationManager.IMPORTANCE_DEFAULT
      val channel = NotificationChannel(id, name, importance)
      notificationManager.createNotificationChannel(channel)

      var builder = NotificationCompat.Builder(applicationContext, id)
          .setSmallIcon(androidx.core.R.drawable.notification_icon_background)
          .setContentTitle("title")
          .setContentText("tweet")
          .setPriority(NotificationCompat.PRIORITY_DEFAULT)

      notificationManager.notify(1, builder.build())
      return Result.success();
  }
}

2. AccessibilityServiceで通知の表示を検知して、Twitterアプリを起動する。

まずはAccessibilityServiceのonAccessibilityEventで通知の表示を検知します。
通知が表示された場合のイベントはTYPE_NOTIFICATION_STATE_CHANGEDで、event.packageNameとapplicationContext.packageNameを比較することで自分自身が表示させた通知であることを確認します。

次にIntentを使いTwitterアプリを起動します。
この時にIntent.EXTRA_TEXTにツイートの内容を設定できます。

Twitterを起動したら、AccessibilityServiceはTYPE_WINDOW_CONTENT_CHANGEDを検知するように設定して処理を終えます。
TYPE_WINDOW_CONTENT_CHANGEDはアプリに描画されている内容に変化があった場合に発生するイベントです。
通知を表示させずに直接Twitterを起動すればよいのではないかと思うかもしれませんが、
手動でTwitterを起動した場合に勝手にツイートボタンを押してしまうのを防ぐため、TYPE_WINDOW_CONTENT_CHANGEDを常に検知しようとすることで端末に負荷がかかるのを防ぐためです。

また、Twitterは同じ内容を連続してツイートすることはできません。ツイート内容にランダムな文字を付け加えるなどして、連投にならないようにしてください。

class TweetAccessibilityService : AccessibilityService() {
    companion object {
        var tweet = false
        var lastTweetText = ""
    }

    override fun onServiceConnected() {
        super.onServiceConnected()
        val info = serviceInfo
        info.eventTypes = AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED or
                AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED
        info.feedbackType = AccessibilityServiceInfo.FEEDBACK_ALL_MASK
        info.notificationTimeout = 1000
        info.flags = AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or
                AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
                AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
        serviceInfo = info
    }

    override fun onAccessibilityEvent(event: AccessibilityEvent) {
        val type = event.eventType;

        when (type) {
            // Notificationの表示に変更があったとき
            AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED -> {
                if (event.packageName.toString() == applicationContext.packageName) {
                    val twitterPackage = "com.twitter.android"
                    if (isPackageInstalled(twitterPackage, applicationContext.packageManager)) {
                        tweet = true;
                        val info = serviceInfo
                        info.eventTypes = AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED
                        serviceInfo = info
                        val texts = TweetBotDb.getInstance(applicationContext)
                            .select(Evaluation("tweet", "text", null))
                            .filter {
                                it.value != lastTweetText
                            }
                        val text = texts.random()
                        lastTweetText = text.value.toString()
                        val currentTimestamp = System.currentTimeMillis()
                        val tweetIntent = Intent(Intent.ACTION_SEND)
                        tweetIntent.setType("text/*")
                        tweetIntent.setPackage(twitterPackage)
                        tweetIntent.putExtra(Intent.EXTRA_TEXT, text.value + " ($currentTimestamp)")
                        tweetIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                        startActivity(tweetIntent)
                    }
                }
            }
        }
    }
    override fun onInterrupt() {}

    private fun isPackageInstalled(packageName: String, packageManager: PackageManager) : Boolean {
        try {
            packageManager.getApplicationInfo(packageName, 0);
            return true;
        } catch (e: PackageManager.NameNotFoundException){
            return false;
        }
    }
}

3. 再びAccessibilityServiceでTwitterアプリの起動を検知&ツイートボタンをタップ

Intentを使いTwitterを起動すると、次のような画面で起動します。
あとはTwitterが起動したことを検知して、ポストするボタンをタップするだけです。

findAccessibilityNodeInfosByViewIdを使いツイートボタンのノードを取得し、performAction(AccessibilityNodeInfo.ACTION_CLICK)でタップすることができます。

  AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED  -> {
      if (event.source?.packageName == "com.twitter.android" && tweet) {
          val root = rootInActiveWindow
          val nodes = root.findAccessibilityNodeInfosByViewId("com.twitter.android:id/button_tweet")
          if (nodes.size > 0) {
              nodes[0].performAction(AccessibilityNodeInfo.ACTION_CLICK)
              tweet = false
              val info = serviceInfo
              info.eventTypes = AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED
              serviceInfo = info

          }
      }
  }

findAccessibilityNodeInfosByViewIdに指定する、ツイートボタンのviewのidはADBを使って確認することができます。

# Twitterのツイート画面を起動
# 起動するアプリを選択する必要あり
adb shell am start -a android.intent.action.SEND -t text/*

# Twitterのツイート画面が表示された状態で
> adb shell uiautomator dump
UI hierchary dumped to: /sdcard/window_dump.xml
> adb pull /sdcard/window_dump.xml ./
/sdcard/window_dump.xml: 1 file pulled, 0 skipped. 0.5 MB/s (17307 bytes in 0.031s)

取得したxmlから画面に表示されているテキストなどを元に必要なノードを探し出し、resource-idを確認します。 findAccessibilityNodeInfosByViewIdの引数に指定するidになります。


注意点

AccessibilityServiceを使うには設定->ユーザー補助->ダウンロードしたアプリで許可を与える必要があります。

またイベント発生時にノードを取得・操作するためにはcanRetrieveWindowContentをtrueに設定する必要があります。

<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/desc"
    android:accessibilityEventTypes="typeWindowContentChanged|typeNotificationStateChanged"
    android:accessibilityFlags="flagReportViewIds|flagDefault|flagRetrieveInteractiveWindows|flagIncludeNotImportantViews"
    android:canRetrieveWindowContent="true"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:notificationTimeout="1000"
    />