gmcp 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +96 -0
- data/LICENSE.txt +21 -0
- data/README.md +248 -0
- data/bin/gmail_survey +167 -0
- data/bin/gmcp +33 -0
- data/bin/gmcp-auth +68 -0
- data/config/capabilities.yml +148 -0
- data/config/scopes.yml +14 -0
- data/lib/gmcp/account_registry.rb +68 -0
- data/lib/gmcp/api_binding.rb +75 -0
- data/lib/gmcp/api_error.rb +33 -0
- data/lib/gmcp/apis.rb +46 -0
- data/lib/gmcp/auth.rb +189 -0
- data/lib/gmcp/bearer_middleware.rb +17 -0
- data/lib/gmcp/calendar/calendar.rb +16 -0
- data/lib/gmcp/calendar/event.rb +95 -0
- data/lib/gmcp/calendar/tools.rb +150 -0
- data/lib/gmcp/capabilities.rb +133 -0
- data/lib/gmcp/drive/file.rb +145 -0
- data/lib/gmcp/drive/tools.rb +64 -0
- data/lib/gmcp/gmail/draft.rb +21 -0
- data/lib/gmcp/gmail/label.rb +16 -0
- data/lib/gmcp/gmail/message.rb +407 -0
- data/lib/gmcp/gmail/mime.rb +108 -0
- data/lib/gmcp/gmail/tools.rb +364 -0
- data/lib/gmcp/gmail/unsubscribe.rb +96 -0
- data/lib/gmcp/server.rb +90 -0
- data/lib/gmcp/tool_helpers.rb +105 -0
- data/lib/gmcp/version.rb +3 -0
- data/lib/gmcp/voice/chrome.rb +111 -0
- data/lib/gmcp/voice/conversation.rb +65 -0
- data/lib/gmcp/voice/folder.rb +67 -0
- data/lib/gmcp/voice/message.rb +81 -0
- data/lib/gmcp/voice/session.rb +126 -0
- data/lib/gmcp/voice/tools.rb +178 -0
- data/lib/gmcp/voice.rb +34 -0
- data/lib/gmcp.rb +43 -0
- metadata +226 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
|
|
7
|
+
module GMCP
|
|
8
|
+
module Voice
|
|
9
|
+
# Google Voice has no OAuth scope, so its session is a set of google.com
|
|
10
|
+
# cookies. GMCP reads them from the Chrome profile already signed in to the
|
|
11
|
+
# account, found by email in Chrome's Local State. Chrome keeps that
|
|
12
|
+
# session alive as it is used, so GMCP never signs in or refreshes anything
|
|
13
|
+
# itself, and an account with no Chrome profile simply has no Voice.
|
|
14
|
+
#
|
|
15
|
+
# macOS only. Chrome encrypts cookie values with AES-128-CBC under a key
|
|
16
|
+
# derived from the "Chrome Safe Storage" Keychain item; reading that item
|
|
17
|
+
# asks the user once, and "Always Allow" makes it silent thereafter.
|
|
18
|
+
module Chrome
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
|
|
21
|
+
KEYCHAIN_SERVICE = 'Chrome Safe Storage'
|
|
22
|
+
|
|
23
|
+
# Cookie values are "v10" + AES-128-CBC(key, iv: 16 spaces). The key is
|
|
24
|
+
# PBKDF2-SHA1 of the Keychain secret, salt "saltysalt", 1003 rounds.
|
|
25
|
+
ENCRYPTED_PREFIX = 'v10'
|
|
26
|
+
# From cookie DB version 24 the plaintext starts with SHA-256(host_key).
|
|
27
|
+
HOST_DIGEST_VERSION = 24
|
|
28
|
+
# Chrome timestamps count microseconds from 1601-01-01.
|
|
29
|
+
CHROME_EPOCH_OFFSET = 11_644_473_600
|
|
30
|
+
|
|
31
|
+
QUERY = <<~SQL.tr("\n", ' ')
|
|
32
|
+
SELECT host_key, name, hex(encrypted_value) AS value, expires_utc
|
|
33
|
+
FROM cookies WHERE host_key = '.google.com' OR host_key LIKE '%.google.com'
|
|
34
|
+
SQL
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def dir
|
|
38
|
+
File.expand_path(ENV.fetch('GMCP_CHROME_DIR', '~/Library/Application Support/Google/Chrome'))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The account's google.com session cookies, as { name => value }.
|
|
42
|
+
def cookies(account)
|
|
43
|
+
db = File.join(profile_for(account), 'Cookies')
|
|
44
|
+
raise Error, "Chrome profile for #{account} has no cookie store" unless File.exist?(db)
|
|
45
|
+
|
|
46
|
+
version = Integer(sqlite(db, "SELECT value FROM meta WHERE key = 'version'").dig(0, 'value'))
|
|
47
|
+
now = Time.now.to_i
|
|
48
|
+
sqlite(db, QUERY)
|
|
49
|
+
.reject { |row| expired?(row['expires_utc'], now) }
|
|
50
|
+
.sort_by { |row| row['host_key'] == '.google.com' ? 1 : 0 } # the shared domain wins on a name clash
|
|
51
|
+
.to_h { |row| [row['name'], decrypt([row['value']].pack('H*'), version)] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The profile directory signed in as account. Chrome records each
|
|
55
|
+
# profile's Google account in Local State's info_cache.
|
|
56
|
+
def profile_for(account)
|
|
57
|
+
state = JSON.parse(File.read(File.join(dir, 'Local State')))
|
|
58
|
+
name, = state.dig('profile', 'info_cache')&.find { |_dir, info| info['user_name'].to_s.casecmp?(account) }
|
|
59
|
+
raise Error, "No Chrome profile is signed in as #{account}. Sign in to Chrome with that account." unless name
|
|
60
|
+
|
|
61
|
+
File.join(dir, name)
|
|
62
|
+
rescue Errno::ENOENT
|
|
63
|
+
raise Error, "Google Chrome is not set up here (no #{File.join(dir, 'Local State')})"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def decrypt(blob, version)
|
|
67
|
+
return blob.force_encoding(Encoding::UTF_8) unless blob.start_with?(ENCRYPTED_PREFIX)
|
|
68
|
+
|
|
69
|
+
cipher = OpenSSL::Cipher.new('aes-128-cbc').decrypt
|
|
70
|
+
cipher.key = key
|
|
71
|
+
cipher.iv = ' ' * 16
|
|
72
|
+
plain = cipher.update(blob.byteslice(ENCRYPTED_PREFIX.bytesize..)) + cipher.final
|
|
73
|
+
plain = plain.byteslice(32..) if version >= HOST_DIGEST_VERSION
|
|
74
|
+
plain.force_encoding(Encoding::UTF_8)
|
|
75
|
+
rescue OpenSSL::Cipher::CipherError
|
|
76
|
+
raise Error, 'Could not decrypt Chrome cookies; the Chrome Safe Storage key did not match'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Test seam.
|
|
80
|
+
def reset!
|
|
81
|
+
@key = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def key
|
|
87
|
+
@key ||= begin
|
|
88
|
+
secret, status = Open3.capture2('security', 'find-generic-password', '-w', '-s', KEYCHAIN_SERVICE)
|
|
89
|
+
raise Error, "Keychain access to #{KEYCHAIN_SERVICE} was denied" unless status.success?
|
|
90
|
+
|
|
91
|
+
OpenSSL::PKCS5.pbkdf2_hmac_sha1(secret.chomp, 'saltysalt', 1003, 16)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Read through sqlite3's immutable mode, so Chrome's own lock on the
|
|
96
|
+
# live database neither blocks the read nor is disturbed by it.
|
|
97
|
+
def sqlite(db, query)
|
|
98
|
+
out, err, status = Open3.capture3('sqlite3', '-json', "file:#{db}?mode=ro&immutable=1", query)
|
|
99
|
+
raise Error, "Could not read #{db}: #{err.strip}" unless status.success?
|
|
100
|
+
|
|
101
|
+
out.strip.empty? ? [] : JSON.parse(out)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def expired?(expires_utc, now)
|
|
105
|
+
micros = expires_utc.to_i
|
|
106
|
+
micros.positive? && (micros / 1_000_000) - CHROME_EPOCH_OFFSET < now
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GMCP
|
|
4
|
+
module Voice
|
|
5
|
+
# A Voice thread: one counterparty's texts, or a call/voicemail record.
|
|
6
|
+
# Decoded from the positional array api2thread/list returns:
|
|
7
|
+
#
|
|
8
|
+
# [id, read, messages, pagination_token, contacts, folders, _, is_text,
|
|
9
|
+
# phone_numbers]
|
|
10
|
+
#
|
|
11
|
+
# Messages come newest first. Text thread ids look like "t.+<number>",
|
|
12
|
+
# call and voicemail ids like "c.<opaque>".
|
|
13
|
+
#
|
|
14
|
+
# Named Conversation rather than Thread so it never shadows ::Thread
|
|
15
|
+
# inside this namespace.
|
|
16
|
+
class Conversation
|
|
17
|
+
attr_reader :id, :messages, :phone_numbers, :names
|
|
18
|
+
|
|
19
|
+
def self.from_jspb(row)
|
|
20
|
+
return nil unless row.is_a?(Array) && row[0].is_a?(String)
|
|
21
|
+
|
|
22
|
+
new(row)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(row)
|
|
26
|
+
@id = row[0]
|
|
27
|
+
@read = row[1] == true
|
|
28
|
+
@messages = Array(row[2]).filter_map { |m| Message.from_jspb(m) }
|
|
29
|
+
|
|
30
|
+
contacts = Array(row[4]).map { |c| Message.split_contact(c) }
|
|
31
|
+
contacts += @messages.map { |m| [m.name, m.phone_number] }
|
|
32
|
+
@names = contacts.map(&:first).compact.uniq
|
|
33
|
+
@phone_numbers = (Array(row[8]).grep(String) + contacts.map(&:last)).compact.uniq
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def read?
|
|
37
|
+
@read
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def latest
|
|
41
|
+
@messages.first
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def counterparty
|
|
45
|
+
[@names.first, @phone_numbers.first].compact.join(' ').then { |s| s.empty? ? @id : s }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Case-insensitive substring match over names, numbers and message text.
|
|
49
|
+
# A query that is mostly digits also matches on digits alone, so
|
|
50
|
+
# "555-0100" finds "+15550100".
|
|
51
|
+
def matches?(query)
|
|
52
|
+
needle = query.to_s.downcase.strip
|
|
53
|
+
return false if needle.empty?
|
|
54
|
+
|
|
55
|
+
haystack = [*@names, *@phone_numbers, *@messages.map(&:text)].compact
|
|
56
|
+
return true if haystack.any? { |s| s.downcase.include?(needle) }
|
|
57
|
+
|
|
58
|
+
digits = needle.gsub(/\D/, '')
|
|
59
|
+
return false if digits.length < 3 || needle.match?(/[a-z]/)
|
|
60
|
+
|
|
61
|
+
@phone_numbers.any? { |p| p.gsub(/\D/, '').include?(digits) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GMCP
|
|
4
|
+
module Voice
|
|
5
|
+
# One page of threads from api2thread/list, the endpoint voice.google.com
|
|
6
|
+
# itself uses. The request is positional:
|
|
7
|
+
#
|
|
8
|
+
# [folder_id, threads_per_page, messages_per_thread, cursor, _, [_, 1, 1, 1]]
|
|
9
|
+
#
|
|
10
|
+
# The trailing array is what the web client always sends. The cursor is the
|
|
11
|
+
# newest-message timestamp (ms, as a string) of the last thread on the
|
|
12
|
+
# previous page; nil asks for the most recent page.
|
|
13
|
+
#
|
|
14
|
+
# The response is [threads, _, version_token].
|
|
15
|
+
class Folder
|
|
16
|
+
PAGE_SIZE = 20
|
|
17
|
+
MESSAGES_PER_THREAD = 15
|
|
18
|
+
SEARCH_PAGES = 10
|
|
19
|
+
MAX_SEARCH_PAGES = 50
|
|
20
|
+
|
|
21
|
+
attr_reader :name, :conversations
|
|
22
|
+
|
|
23
|
+
def self.fetch(session, name = 'all', cursor: nil)
|
|
24
|
+
id = FOLDERS.fetch(name.to_s) do
|
|
25
|
+
raise ArgumentError, "Unknown folder: #{name}. Valid: #{FOLDERS.keys.join(', ')}"
|
|
26
|
+
end
|
|
27
|
+
body = [id, PAGE_SIZE, MESSAGES_PER_THREAD, cursor&.to_s, nil, [nil, 1, 1, 1]]
|
|
28
|
+
new(name.to_s, session.call('api2thread/list', body))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The API has no server-side search we can call, so walk recent pages
|
|
32
|
+
# and match locally. Bounded, because the call log reaches back years
|
|
33
|
+
# and each page is a round trip.
|
|
34
|
+
def self.search(session, query, folder: 'all', pages: SEARCH_PAGES)
|
|
35
|
+
pages = pages.to_i.clamp(1, MAX_SEARCH_PAGES)
|
|
36
|
+
matches = []
|
|
37
|
+
scanned = 0
|
|
38
|
+
cursor = nil
|
|
39
|
+
pages.times do
|
|
40
|
+
page = fetch(session, folder, cursor: cursor)
|
|
41
|
+
scanned += page.conversations.size
|
|
42
|
+
matches.concat(page.conversations.select { |c| c.matches?(query) })
|
|
43
|
+
following = page.next_cursor
|
|
44
|
+
break if following.nil? || following == cursor
|
|
45
|
+
|
|
46
|
+
cursor = following
|
|
47
|
+
end
|
|
48
|
+
[matches, scanned]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(name, response)
|
|
52
|
+
@name = name
|
|
53
|
+
rows = response.is_a?(Array) && response[0].is_a?(Array) ? response[0] : []
|
|
54
|
+
@conversations = rows.filter_map { |row| Conversation.from_jspb(row) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Cursor for the page after this one, or nil when this page was empty.
|
|
58
|
+
def next_cursor
|
|
59
|
+
@conversations.last&.latest&.timestamp_ms&.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def inspect
|
|
63
|
+
"#<Voice::Folder #{@name} (#{@conversations.size} threads)>"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GMCP
|
|
4
|
+
module Voice
|
|
5
|
+
# One call, voicemail, or text inside a Conversation, decoded from the
|
|
6
|
+
# positional (JSPB) array the API returns. Index N holds proto field N+1:
|
|
7
|
+
#
|
|
8
|
+
# [id, timestamp_ms, destination_id, contact, type, status, transcript,
|
|
9
|
+
# _, duration_s, text, _, media_url, coarse_type, transcript_status, mms]
|
|
10
|
+
#
|
|
11
|
+
# contact is [name, phone_number]; mms is [text, subject, attachments, ...];
|
|
12
|
+
# transcript is [confidence, [[token_bytes_base64, ...], ...]].
|
|
13
|
+
class Message
|
|
14
|
+
attr_reader :id, :timestamp_ms, :type, :name, :phone_number, :duration, :text
|
|
15
|
+
|
|
16
|
+
def self.from_jspb(row)
|
|
17
|
+
return nil unless row.is_a?(Array) && row[0].is_a?(String)
|
|
18
|
+
|
|
19
|
+
new(row)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(row)
|
|
23
|
+
@id = row[0]
|
|
24
|
+
@timestamp_ms = integer(row[1])
|
|
25
|
+
@name, @phone_number = Message.split_contact(row[3])
|
|
26
|
+
@type = integer(row[4])
|
|
27
|
+
@read = integer(row[5]) == 1
|
|
28
|
+
@duration = row[8].is_a?(Numeric) ? row[8] : nil
|
|
29
|
+
@text = present(row[9]) || present(row[14].is_a?(Array) ? row[14][0] : nil) || transcript(row[6])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def read?
|
|
33
|
+
@read
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def type_name
|
|
37
|
+
MESSAGE_TYPES.fetch(@type, "type#{@type}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def time
|
|
41
|
+
Time.at(@timestamp_ms / 1000.0) if @timestamp_ms
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A contact is [name, phone], but either slot may be empty, so classify
|
|
45
|
+
# by content rather than trusting position for which is the number.
|
|
46
|
+
def self.split_contact(contact)
|
|
47
|
+
strings = Array(contact).grep(String).reject(&:empty?)
|
|
48
|
+
phone = strings.find { |s| s.match?(/\A\+?[\d\s().-]{3,}\z/) }
|
|
49
|
+
[(strings - [phone]).first, phone]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def integer(value)
|
|
55
|
+
Integer(value)
|
|
56
|
+
rescue ArgumentError, TypeError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def present(value)
|
|
61
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Voicemail transcripts arrive as base64 token bytes. Anything that does
|
|
65
|
+
# not decode to valid UTF-8 is used as-is rather than dropped.
|
|
66
|
+
def transcript(raw)
|
|
67
|
+
tokens = raw.is_a?(Array) && raw[1].is_a?(Array) ? raw[1] : []
|
|
68
|
+
words = tokens.filter_map do |token|
|
|
69
|
+
bytes = token.is_a?(Array) ? token[0] : nil
|
|
70
|
+
next unless bytes.is_a?(String)
|
|
71
|
+
|
|
72
|
+
decoded = bytes.unpack1('m0').force_encoding('UTF-8')
|
|
73
|
+
decoded.valid_encoding? ? decoded : bytes
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
bytes
|
|
76
|
+
end
|
|
77
|
+
words.empty? ? nil : words.join(' ')
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'digest'
|
|
7
|
+
|
|
8
|
+
module GMCP
|
|
9
|
+
module Voice
|
|
10
|
+
# Authenticated client for the internal Google Voice JSPB/protojson API.
|
|
11
|
+
#
|
|
12
|
+
# The "public" Google Voice API is what the voice.google.com SPA calls
|
|
13
|
+
# internally at https://clients6.google.com/voice/v1/voiceclient/...
|
|
14
|
+
# Auth is cookie-based plus a SAPISIDHASH triple Authorization header and
|
|
15
|
+
# a public API key embedded in the voice.google.com page source.
|
|
16
|
+
#
|
|
17
|
+
# No OAuth — the Google OAuth2 scope required for the token→cookie
|
|
18
|
+
# exchange (OAuthLogin) is reserved for Chromium and isn't grantable to
|
|
19
|
+
# external clients. The cookies come from the Chrome profile signed in to
|
|
20
|
+
# the account instead; see Voice::Chrome.
|
|
21
|
+
class Session
|
|
22
|
+
class AuthError < StandardError; end
|
|
23
|
+
class OperationError < StandardError; end
|
|
24
|
+
|
|
25
|
+
API_HOST = 'https://clients6.google.com'
|
|
26
|
+
API_PATH = '/voice/v1/voiceclient/'
|
|
27
|
+
# Browser API key published in the voice.google.com page source — it is
|
|
28
|
+
# not a secret and identifies Google's own web client, not the user.
|
|
29
|
+
# Overridable because Google can rotate it at any time, and a rotation
|
|
30
|
+
# should not require editing the gem.
|
|
31
|
+
API_KEY = ENV.fetch('GMCP_VOICE_API_KEY', 'AIzaSyDTYc1N4xiODyrQYK0Kl6g_y279LjYkrBg')
|
|
32
|
+
ORIGIN = 'https://voice.google.com'
|
|
33
|
+
|
|
34
|
+
AUTH_FAILURE_CODES = %w[401 403].freeze
|
|
35
|
+
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
|
|
36
|
+
|
|
37
|
+
# Cookies the browser sends to google.com on every request.
|
|
38
|
+
# SAPISID / __Secure-1PAPISID / __Secure-3PAPISID drive the auth hashes;
|
|
39
|
+
# SID / HSID / SSID / NID identify the session.
|
|
40
|
+
SESSION_COOKIE_NAMES = %w[
|
|
41
|
+
SID HSID SSID APISID SAPISID
|
|
42
|
+
__Secure-1PSID __Secure-3PSID
|
|
43
|
+
__Secure-1PAPISID __Secure-3PAPISID
|
|
44
|
+
__Secure-1PSIDCC __Secure-3PSIDCC
|
|
45
|
+
__Secure-1PSIDTS __Secure-3PSIDTS
|
|
46
|
+
NID
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# A session for one account, from its GMCP Chrome profile.
|
|
50
|
+
def self.for(account)
|
|
51
|
+
new(cookies: Chrome.cookies(account))
|
|
52
|
+
rescue Chrome::Error => e
|
|
53
|
+
raise AuthError, e.message
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The Authorization header Google's internal APIs expect is three
|
|
57
|
+
# space-separated <tag> <ts>_<sha1(ts SP cookie SP origin)> chunks.
|
|
58
|
+
HASH_COOKIES = [
|
|
59
|
+
['SAPISIDHASH', 'SAPISID'],
|
|
60
|
+
['SAPISID1PHASH', '__Secure-1PAPISID'],
|
|
61
|
+
['SAPISID3PHASH', '__Secure-3PAPISID']
|
|
62
|
+
].freeze
|
|
63
|
+
|
|
64
|
+
def initialize(cookies:)
|
|
65
|
+
@cookies = cookies.slice(*SESSION_COOKIE_NAMES)
|
|
66
|
+
raise AuthError, 'No Google session (missing SAPISID). Sign in to Google in that Chrome profile.' unless @cookies['SAPISID']
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Call a Voice API method. `path` is something like "account/get" or
|
|
70
|
+
# "thread/batchupdateattributes". `body` is the JSPB request payload
|
|
71
|
+
# (usually an array, e.g. [] for empty, [[["id1","id2"]]] for a list).
|
|
72
|
+
# Returns the parsed JSON response.
|
|
73
|
+
def call(path, body = [])
|
|
74
|
+
uri = URI("#{API_HOST}#{API_PATH}#{path}?alt=protojson&key=#{API_KEY}")
|
|
75
|
+
req = Net::HTTP::Post.new(uri)
|
|
76
|
+
headers.each { |k, v| req[k] = v }
|
|
77
|
+
req.body = JSON.generate(body)
|
|
78
|
+
|
|
79
|
+
resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
|
|
80
|
+
parsed = JSON.parse(resp.body) rescue nil
|
|
81
|
+
|
|
82
|
+
unless resp.is_a?(Net::HTTPSuccess)
|
|
83
|
+
msg = parsed.is_a?(Hash) && parsed.dig('error', 'message') || resp.body.to_s[0, 300]
|
|
84
|
+
# Google rotates __Secure-*PSIDTS and friends under a long-lived
|
|
85
|
+
# process, so a rejection here usually means our snapshot of the
|
|
86
|
+
# cookies is stale rather than that the call was malformed. AuthError
|
|
87
|
+
# tells the caller to fetch a fresh session.
|
|
88
|
+
raise AuthError, "#{path}: HTTP #{resp.code} — #{msg}" if AUTH_FAILURE_CODES.include?(resp.code.to_s)
|
|
89
|
+
|
|
90
|
+
raise OperationError, "#{path}: HTTP #{resp.code} — #{msg}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
parsed
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def headers
|
|
99
|
+
{
|
|
100
|
+
'Authorization' => sapisidhash_header,
|
|
101
|
+
'Cookie' => cookie_header,
|
|
102
|
+
'Content-Type' => 'application/json+protobuf',
|
|
103
|
+
'Origin' => ORIGIN,
|
|
104
|
+
'Referer' => "#{ORIGIN}/",
|
|
105
|
+
'User-Agent' => USER_AGENT,
|
|
106
|
+
'X-Goog-AuthUser' => '0',
|
|
107
|
+
'X-Goog-Encode-Response-If-Executable' => 'base64',
|
|
108
|
+
'X-JavaScript-User-Agent' => 'google-api-javascript-client/1.1.0',
|
|
109
|
+
'X-Requested-With' => 'XMLHttpRequest'
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def sapisidhash_header
|
|
114
|
+
ts = Time.now.to_i
|
|
115
|
+
HASH_COOKIES.filter_map do |tag, name|
|
|
116
|
+
val = @cookies[name] or next
|
|
117
|
+
"#{tag} #{ts}_#{Digest::SHA1.hexdigest("#{ts} #{val} #{ORIGIN}")}"
|
|
118
|
+
end.join(' ')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def cookie_header
|
|
122
|
+
@cookies.map { |k, v| "#{k}=#{v}" }.join('; ')
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mcp'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module GMCP
|
|
7
|
+
module Voice
|
|
8
|
+
# MCP tools for Google Voice.
|
|
9
|
+
#
|
|
10
|
+
# Each takes the same `account:` as the other services and is limited to
|
|
11
|
+
# GMCP_ACCOUNTS, but does not go through Server.with_account: Voice has no
|
|
12
|
+
# OAuth scope, so its session comes from the Chrome profile signed in to
|
|
13
|
+
# the account (see Voice::Chrome).
|
|
14
|
+
module Tools
|
|
15
|
+
def self.register(server)
|
|
16
|
+
register_read(server)
|
|
17
|
+
register_write(server)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.register_read(server)
|
|
21
|
+
ToolHelpers.define_tool(
|
|
22
|
+
server,
|
|
23
|
+
name: 'voice_account',
|
|
24
|
+
capability: 'voice.read',
|
|
25
|
+
description: 'Show the Google Voice number of an account.',
|
|
26
|
+
properties: { **ToolHelpers::ACCOUNT_PARAM }
|
|
27
|
+
) do |account: nil|
|
|
28
|
+
Tools.guarding(account) do |acct|
|
|
29
|
+
number = Tools.account_number(acct)
|
|
30
|
+
ToolHelpers.text_response(
|
|
31
|
+
number ? "Voice number for #{acct}: #{number}"
|
|
32
|
+
: "#{acct} has no Google Voice number."
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
ToolHelpers.define_tool(
|
|
38
|
+
server,
|
|
39
|
+
name: 'voice_list',
|
|
40
|
+
capability: 'voice.read',
|
|
41
|
+
description: 'List Google Voice threads (texts, calls, voicemail), newest first, 20 per page. ' \
|
|
42
|
+
"Folders: #{FOLDERS.keys.join(', ')}. Pass the returned next_cursor to get the next page.",
|
|
43
|
+
properties: {
|
|
44
|
+
folder: { type: 'string', description: 'Folder name (default: all)' },
|
|
45
|
+
cursor: { type: 'string', description: 'next_cursor from a previous voice_list call' },
|
|
46
|
+
**ToolHelpers::ACCOUNT_PARAM
|
|
47
|
+
}
|
|
48
|
+
) do |folder: 'all', cursor: nil, account: nil|
|
|
49
|
+
Tools.guarding(account) do |acct|
|
|
50
|
+
page = Folder.fetch(Tools.session(acct), folder, cursor: cursor)
|
|
51
|
+
lines = page.conversations.map { |c| Tools.format(c) }
|
|
52
|
+
next ToolHelpers.text_response("No threads in #{folder}.") if lines.empty?
|
|
53
|
+
|
|
54
|
+
lines << "next_cursor: #{page.next_cursor}"
|
|
55
|
+
ToolHelpers.text_response(lines.join("\n"))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
ToolHelpers.define_tool(
|
|
60
|
+
server,
|
|
61
|
+
name: 'voice_search',
|
|
62
|
+
capability: 'voice.read',
|
|
63
|
+
description: 'Search recent Google Voice threads by contact name, phone number, or message text. ' \
|
|
64
|
+
'Matches locally over the newest pages of a folder (20 threads per page), so older ' \
|
|
65
|
+
'history is only reached by raising pages or narrowing the folder.',
|
|
66
|
+
properties: {
|
|
67
|
+
query: { type: 'string', description: 'Text, name, or phone number to look for' },
|
|
68
|
+
folder: { type: 'string', description: "Folder to scan (default: all). One of #{FOLDERS.keys.join(', ')}" },
|
|
69
|
+
pages: { type: 'integer', description: "Pages to scan (default: #{Folder::SEARCH_PAGES}, max: #{Folder::MAX_SEARCH_PAGES})" },
|
|
70
|
+
**ToolHelpers::ACCOUNT_PARAM
|
|
71
|
+
},
|
|
72
|
+
required: ['query']
|
|
73
|
+
) do |query:, folder: 'all', pages: Folder::SEARCH_PAGES, account: nil|
|
|
74
|
+
Tools.guarding(account) do |acct|
|
|
75
|
+
matches, scanned = Folder.search(Tools.session(acct), query, folder: folder, pages: pages)
|
|
76
|
+
ToolHelpers.list_response(matches, empty_message: "No matches in the #{scanned} most recent #{folder} threads.") do |c|
|
|
77
|
+
Tools.format(c)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.register_write(server)
|
|
84
|
+
ToolHelpers.define_tool(
|
|
85
|
+
server,
|
|
86
|
+
name: 'voice_mark_read',
|
|
87
|
+
capability: 'voice.modify',
|
|
88
|
+
description: 'Mark a Google Voice thread as read. Takes a thread id from voice_list or voice_search.',
|
|
89
|
+
properties: {
|
|
90
|
+
thread_id: { type: 'string' },
|
|
91
|
+
**ToolHelpers::ACCOUNT_PARAM
|
|
92
|
+
},
|
|
93
|
+
required: ['thread_id']
|
|
94
|
+
) do |thread_id:, account: nil|
|
|
95
|
+
Tools.guarding(account) do |acct|
|
|
96
|
+
Tools.session(acct).call('thread/updateattributes', Tools.mark_read_body(thread_id))
|
|
97
|
+
ToolHelpers.text_response("Thread #{thread_id} marked read.")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class << self
|
|
103
|
+
# One session per account, reused across calls until Google rejects it.
|
|
104
|
+
def session(account)
|
|
105
|
+
lock.synchronize { sessions[account] ||= Session.for(account) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Drop memoized sessions so the next call fetches fresh cookies.
|
|
109
|
+
# guarding calls this for an account on every auth failure.
|
|
110
|
+
def reset_session!(account = nil)
|
|
111
|
+
lock.synchronize { account ? sessions.delete(account) : sessions.clear }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Voice number for an account, or nil when the account has no Voice
|
|
115
|
+
# (account/get answers NOT_FOUND). Shape of account/get is
|
|
116
|
+
# [["+1XXXXXXXXXX", ...]].
|
|
117
|
+
def account_number(account)
|
|
118
|
+
resp = session(account).call('account/get', [])
|
|
119
|
+
resp.dig(0, 0) if resp.is_a?(Array)
|
|
120
|
+
rescue Session::OperationError => e
|
|
121
|
+
raise unless e.message.include?('NOT_FOUND')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# thread/updateattributes takes the new attributes, then a second
|
|
125
|
+
# attributes record naming which fields to change, then 1. Only `read`
|
|
126
|
+
# (field 4) is set in both, so nothing else about the thread is touched.
|
|
127
|
+
# Shape taken from a working open-source Voice client, not from probing:
|
|
128
|
+
# mutations are never sent to a live account to discover their format.
|
|
129
|
+
def mark_read_body(thread_id)
|
|
130
|
+
[
|
|
131
|
+
[thread_id, nil, nil, true, nil, nil, nil, nil],
|
|
132
|
+
[nil, nil, nil, true, nil, nil, nil, nil],
|
|
133
|
+
1
|
|
134
|
+
]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def format(conversation)
|
|
138
|
+
latest = conversation.latest
|
|
139
|
+
parts = [conversation.id, "[#{latest&.type_name || 'empty'}]", conversation.counterparty]
|
|
140
|
+
parts << latest.time.iso8601 if latest&.time
|
|
141
|
+
parts << "read=#{conversation.read?}"
|
|
142
|
+
preview = latest&.text.to_s.gsub(/\s+/, ' ').strip
|
|
143
|
+
parts << "— #{preview[0, 120]}" unless preview.empty?
|
|
144
|
+
parts.join(' ')
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Resolves the account against GMCP_ACCOUNTS and gives every voice tool
|
|
148
|
+
# one error surface, with advice the generic ToolHelpers.guarded cannot
|
|
149
|
+
# give. An auth failure also drops that account's session, so a retry
|
|
150
|
+
# re-reads the profile instead of reusing rejected cookies.
|
|
151
|
+
def guarding(account)
|
|
152
|
+
account = GMCP::Server.configured_account(account)
|
|
153
|
+
begin
|
|
154
|
+
yield account
|
|
155
|
+
rescue Session::AuthError => e
|
|
156
|
+
reset_session!(account)
|
|
157
|
+
ToolHelpers.error_response(
|
|
158
|
+
"Voice auth failed for #{account}: #{e.message}\n" \
|
|
159
|
+
"If this persists, open Google Voice in that account's Chrome profile to refresh its session."
|
|
160
|
+
)
|
|
161
|
+
rescue Session::OperationError => e
|
|
162
|
+
ToolHelpers.error_response("Voice API error: #{e.message}")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def sessions
|
|
169
|
+
@sessions ||= {}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def lock
|
|
173
|
+
@lock ||= Mutex.new
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/gmcp/voice.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GMCP
|
|
4
|
+
module Voice
|
|
5
|
+
# Thread folders as api2thread/list numbers them. "all" is the unified
|
|
6
|
+
# Messages+Calls view, which Google caps at a few hundred recent threads;
|
|
7
|
+
# the narrower folders reach further back.
|
|
8
|
+
FOLDERS = {
|
|
9
|
+
'all' => 1,
|
|
10
|
+
'messages' => 2,
|
|
11
|
+
'calls' => 3,
|
|
12
|
+
'voicemail' => 4,
|
|
13
|
+
'spam' => 5,
|
|
14
|
+
'archived' => 6
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
MESSAGE_TYPES = {
|
|
18
|
+
0 => 'missed',
|
|
19
|
+
1 => 'received',
|
|
20
|
+
2 => 'voicemail',
|
|
21
|
+
7 => 'placed',
|
|
22
|
+
10 => 'sms.received',
|
|
23
|
+
11 => 'sms.sent',
|
|
24
|
+
14 => 'placed.cancelled'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
autoload :Session, 'gmcp/voice/session'
|
|
28
|
+
autoload :Message, 'gmcp/voice/message'
|
|
29
|
+
autoload :Conversation, 'gmcp/voice/conversation'
|
|
30
|
+
autoload :Folder, 'gmcp/voice/folder'
|
|
31
|
+
autoload :Tools, 'gmcp/voice/tools'
|
|
32
|
+
autoload :Chrome, 'gmcp/voice/chrome'
|
|
33
|
+
end
|
|
34
|
+
end
|