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.
@@ -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