宅電着信時に発信者情報をLine通知で受け取る

# 目的
自宅の固定電話への勧誘電話に出たくない。
電話機に発信者情報が非通知の場合に呼び出し音を鳴らさない機能があるが、発信者番号を非通知でない悪質業者を拒否するのは難しい。トビラフォンのような装置・サービスもあるがランニングコストがかかってしまう。
ということで、フリマサイトで日興電機製作所製のCTIアダプタ（アロハシリーズ）を入手して発信者情報をLine通知で確認する仕組みをで自作してみました。

# 環境
OS：Windows10
言語：Windows PowerShell Version 5.1
CTIアダプタ：日興電機製作所 アロハシリーズ（ＮＤ４とＮＤ６で動作確認しました）
USBデバイスサーバ：Silex DS-600（もっと安価な機種で構いません）

# 仕組み

1. CTIアダプタで検知した発信者情報を USB ⇒ USBデバイスドライバ経由で PC に送る。PCを電話機の近くに置けるならUSBデバイスサーバは不要（CTIアダプタとPCをUSBケーブルで直結する）。
1. 発信者の番号を JUNKCALL ウェブサイト（https://junkcall.org/jp/ ）で検索して検索結果の発信者情報（画像ファイル）をダウンロードする。
1. Google Cloud Vision API の OCR 機能で画像ファイルに含まれる文字情報（業者名称等）を抽出する。
1. 発信者情報を Line Nofify API 経由で指定の Lineトークルームへ送信する。


```mermaid
flowchart TB
  landline1([電話回線])-->cti[アロハＮＤ]  
　cti-->usb([USB])-->|発信者情報-電話番号|ds[USBデバイスサーバ]
　cti-->landline2([電話回線])-->|音声信号|固定電話
  ds-->lan([LAN])-->ap[無線AP]-->Wifi([Wifi])-->pc
  pc-->inet1a([Internet])-->|発信者情報-電話番号|jc[JUNKCALL]-->inet1b([Internet])-->|発信者情報-画像|pc
  pc-->inet2a([Internet])-->|発信者情報-画像|ocr[Cloud Vision API]-->inet2b([Internet])-->|発信者情報-テキスト|pc
  pc-->inet3a([Internet])-->|発信者情報-テキスト|line[Line Notify API]-->inet3b([Internet])-->phone[スマートフォン]
```

```mermaid
sequenceDiagram
    アロハＮＤ->>+PC: 発信者情報（電話番号）
    PC-->>+アロハＮＤ: ACK（0x06）for ND4
    PC->>+JUNKCALL: 発信者情報（電話番号）
    JUNKCALL->>-PC: 発信者情報（画像）
    PC->>+Cloud Vision API: 発信者情報（画像）
    Cloud Vision API->>-PC: 発信者情報（テキスト）
    PC->>+Line Notify API: 発信者情報（テキスト）
    Line Notify API->>+スマートフォン: 発信者情報（Line通知）
```

# Line通知のイメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189363/dde2893b-5775-9767-2165-03cabe79d6f9.png)


# コード

- 12行目の`$token`にはダミーのアクセストークンが設定されています。Line Notify APIのアクセストークンを取得して`$token`の設定を変更してください("Bearer " の後に記載します)。アクセストークンの取得方法については色々なサイトで具体的な方法が説明されていますので Google などで検索してください。たとえば[こちら](https://algorithm.joho.info/windows10/line-notify-access-token/)。
- 23行目の`$apiKey`にはダミーのAPIキーが設定されています。Google Cloud Vision API の APIキーを取得して`$apiKey`の設定を変更してください。APIキーの取得方法については色々なサイトで具体的な方法が説明されていますので Google などで検索してください。たとえば[こちら](https://qiita.com/njn0te/items/24ee315ceea2a1879588)。
注意：APIの使用回数が月間1000件を超えると課金が発生します。
- `$addressBook` に身近な人（家族など）の電話番号を登録しておくと便利です。電話番号の文字列は右側に半角空白を追加して全体で20文字になるようにしてください。
- タスクトレイに常駐します。終了する場合はアイコンを右クリックして Exit を選択します。
- 一行目の先頭の`#`を削除することでバッチファイルとして起動することも可能です（その場合は、拡張子を.batに変更）。


```whoscall.ps1
#@Powershell -NoP -W Hidden -C "$PSCP='%~f0';&([ScriptBlock]::Create((gc '%~f0'|?{$_.ReadCount -gt 1}|Out-String)))" & exit/b
# V1.03 by earthdiver1
if ($PSCommandPath) {
    $PSCP = $PSCommandPath
    $code = '[DllImport("user32.dll")]public static extern bool ShowWindowAsync(IntPtr hWnd,int nCmdShow);'
    $type = Add-Type -MemberDefinition $code -Name Win32ShowWindowAsync -PassThru
    [void]$type::ShowWindowAsync((Get-Process -PID $PID).MainWindowHandle,0) 
}

Start-Transcript -LiteralPath ($PSCP -Replace '\..*?$',".log") -Append

$token  = "Bearer DUMMYNpQYFlpNwRo5EGCPo9YoXWFJGF2voeWxEeGYVi" # Line Notify API のアクセストークン

$addressBook = @{
   "090XXXXXXXX         " = "父の携帯"
   "090YYYYYYYY         " = "母の携帯"
}

function GoogleOCR {
   param (
    [Parameter(Mandatory)][String]$imgurl
   )
   $apiKey = "DUMMYyD_haoPC5QPuMs6kKgr1JEMNnMbyDm6qbs" # Google Cloud Vision API のAPIキー
   $uri    = "https://vision.googleapis.com/v1/images:annotate?key=" + $apiKey

   $text = ""

   try {
      $imgFile = [IO.Path]::GetTempFileName() | Rename-Item -NewName { $_.Replace(".tmp",".png") } -PassThru
      Invoke-WebRequest -Uri $imgurl -Outfile $imgFile
      $b64 = [convert]::ToBase64String(( Get-Content $imgFile -Encoding Byte -Raw ))
      $body = @"
{
   "requests": [{
      "image": {
         "content": "$b64"
      },
      "features": [{
         "type": "TEXT_DETECTION",
         "maxResults": 1
      }],
      "imageContext": {
         "languageHints": ["ja"]
      }
   }]
}
"@
      $response = Invoke-WebRequest -Uri $uri -Method Post -Body $body -ContentType "application/json"
      if ( $response.StatusCode -eq "200" ) {
         $text = ($response.Content | ConvertFrom-Json).responses.fullTextAnnotation.text
      } else {
         $text = ""
      }
   } finally {
      if ( Test-Path $imgFile ) { Remove-Item $imgFile -Force }
   }
   return $text
}

$syncHash = [hashtable]::Synchronized(@{})
$syncHash.PSCP = $PSCP
$runspace = [RunspaceFactory]::CreateRunspace()
$runspace.ApartmentState = "STA"
$runspace.ThreadOptions  = "ReuseThread"
$runspace.Open()
$runspace.SessionStateProxy.SetVariable("syncHash",$syncHash)
$action=[ScriptBlock]::Create(@'
   Add-Type -AssemblyName System.Windows.Forms, System.Drawing
   $menuItem = New-Object System.Windows.Forms.MenuItem "Exit"
   $menuItem.add_Click({$notifyIcon.Visible=$False;$appContext.ExitThread()})
   $contextMenu = New-Object System.Windows.Forms.ContextMenu
   $contextMenu.MenuItems.AddRange($menuItem)
   $notifyIcon  = New-Object System.Windows.Forms.NotifyIcon
   $notifyIcon.ContextMenu = $contextMenu
   $notifyIcon.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon($syncHash.PSCP)
   $notifyIcon.Text = (Get-ChildItem $syncHash.PSCP).BaseName
   $notifyIcon.Visible = $True
   $appContext = New-Object System.Windows.Forms.ApplicationContext
   $syncHash.appContext = $appContext
   $syncHash.notifyIcon = $notifyIcon
   [void][System.Windows.Forms.Application]::Run($appContext)
'@)
$PS = [PowerShell]::Create().AddScript($action)
$PS.Runspace = $runspace
$status = $PS.BeginInvoke()

while ( -not $syncHash.notifyIcon.Visible ) { Start-Sleep    1 }

$data = @{
   GoogleOCR   = $function:GoogleOCR
   addressBook = $addressBook
   header      = @{ Authorization = "$token" }
   uri         = "https://notify-api.line.me/api/notify"
}

try {
   $wmiObject = Get-WmiObject -Class Win32_PnPEntity -Filter "ClassGuid='{4d36e978-e325-11ce-bfc1-08002be10318}' AND Description='Prolific USB-to-Serial Comm Port'"
   $serialPort = $wmiObject.Name -replace '^.*\((COM\d)\).*$', '$1'
   $com = New-Object System.IO.Ports.SerialPort $serialPort
   $com.BaudRate  = 9600
   $com.DataBits  = 7
   $com.Handshake = [IO.Ports.Handshake]::None
   $com.Parity    = [IO.Ports.Parity]::Even
   $com.StopBits  = [IO.Ports.StopBits]::One
   $com.NewLine   = [Environment]::NewLine
   $com.Open()

   Register-ObjectEvent -InputObject $com -EventName "DataReceived" -MessageData $data -Action {
      param( [IO.Ports.SerialPort]$s, [EventArgs]$e )
      $s.Write(0x06,0,1)  # for ND4
      $addressBook        = $Event.MessageData.addressBook
      $function:GoogleOCR = $Event.MessageData.GoogleOCR
      $header             = $Event.MessageData.header
      $uri                = $Event.MessageData.uri
      $buffer = $s.ReadExisting()
      if ( [string]::IsNullOrEmpty($buffer) ) { return }
      $data = @( $buffer -split '\x02([^\x03]+)\x03' | ? { $_ } )
      foreach ( $datum in $data ) {
         if ( $datum.length -ne 29 ) { continue }
         $month  = $datum.Substring(0,2)
         $day    = $datum.Substring(2,2)
         $dow    = ("月","火","水","木","金","土","日")[($datum.Substring(4,1) - 1)]
         $hour   = $datum.Substring(5,2)
         $minute = $datum.Substring(7,2)
         $date   = "$month/$day($dow) ${hour}:$minute"
         $tel    = $datum.Substring(9,20)

         Write-Host $date $tel

         $imgurl = ""
         $msg    = ""
         switch ( $tel ) {
            "C                   " { $tel = "公衆電話"   }
            "E                   " { $tel = "－Ｅ－"     }
            "O                   " { $tel = "表示圏外"   }
            "P                   " { $tel = "非通知"     }
            "S                   " { $tel = "表示圏外"   }
            default {
               if ( $addressBook.ContainsKey($tel) ) { $tel = $addressBook["$tel"] ; break }
               $tel  = $tel.Trim()
               $html = Invoke-RestMethod -Uri https://junkcall.org/jp/?q=$tel
               $html = [Text.Encoding]::UTF8.GetString( [Text.Encoding]::GetEncoding("ISO-8859-1").GetBytes($html) )
               if ($html -match '一致する電話番号を見つかりませんでした' ) {
                  $msg = "`n一致する電話番号が見つかりませんでした。"
               } elseif ( $html -match '(名称|コミュニティレポート)情報:' ) {
                  $imgurl = $html -replace '(?s)\A.+?(?:名称|コミュニティレポート)情報:.+?(https://i\.junkcall\.org/txt2img/.+?\.png).*\Z', '$1'
                  $msg = "`n発信者：" + ( GoogleOCR $imgurl )
                  if ( $msg ) { $imgurl = "" }
               } else {
                  $msg = "`n不明なエラーです"
               }
            }
         }
         if ( $imgurl ) {
            $body = @{ "message" = "$tel から着信" 
                       "imageFullsize"  = $imgurl
                       "imageThumbnail" = $imgurl
                     }
         } else {
            $body = @{ "message" = "$tel から着信$msg" }
         }
         Invoke-RestMethod -Uri $uri -Method Post -Headers $header -Body $body
      }
   }

   while ( $syncHash.notifyIcon.Visible ) {
      if ( $com.IsOpen ) {
         Wait-Event -Timeout 1
      } else {
         Write-Host "$(Get-Date) 接続が切れています。再接続しています．．．"
         try {
            $com.Open() | Out-Null
         } catch {}
         if ( $com.IsOpen ) {
            Write-Host "$(Get-Date) 再接続に成功しました。"
         } else {
            Start-Sleep 60
         }
      }
   }
} catch {
   $syncHash.notifyIcon.Visible = $false
   $syncHash.appContext.ExitThread()
   Add-Type -AssemblyName System.Windows.Forms, System.Drawing
   $notifyIcon  = New-Object System.Windows.Forms.NotifyIcon
   $notifyIcon.Icon = [Drawing.SystemIcons]::Error
   $notifyIcon.Visible = $true
   $notifyIcon.ShowBalloonTip(10000, "エラー", "$((Get-Item $PSCP).BaseName) は異常終了しました", "Error")
   $notifyIcon.Visible = $false
   $notifyIcon.Dispose()
   throw
} finally {
   $com.Close()
   $com.Dispose()
}
```


　
　
クリエイティブ・コモンズ 表示 - 継承 4.0 国際