agent-messenger 2.1.0 → 2.2.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.env.template +35 -17
- package/README.md +7 -7
- package/bun.lock +6 -6
- package/dist/package.json +2 -2
- package/dist/src/platforms/channeltalk/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/commands/auth.js +35 -28
- package/dist/src/platforms/channeltalk/commands/auth.js.map +1 -1
- package/dist/src/platforms/channeltalk/ensure-auth.js +6 -6
- package/dist/src/platforms/channeltalk/ensure-auth.js.map +1 -1
- package/dist/src/platforms/channeltalk/token-extractor.d.ts +23 -1
- package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/channeltalk/token-extractor.js +299 -29
- package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
- package/dist/src/platforms/discord/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/auth.js +57 -49
- package/dist/src/platforms/discord/commands/auth.js.map +1 -1
- package/dist/src/platforms/discord/ensure-auth.js +3 -3
- package/dist/src/platforms/discord/ensure-auth.js.map +1 -1
- package/dist/src/platforms/discord/token-extractor.d.ts +6 -1
- package/dist/src/platforms/discord/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/discord/token-extractor.js +167 -14
- package/dist/src/platforms/discord/token-extractor.js.map +1 -1
- package/dist/src/platforms/instagram/client.d.ts +2 -0
- package/dist/src/platforms/instagram/client.d.ts.map +1 -1
- package/dist/src/platforms/instagram/client.js +2 -2
- package/dist/src/platforms/instagram/client.js.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.js +107 -14
- package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
- package/dist/src/platforms/instagram/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/instagram/ensure-auth.js +57 -11
- package/dist/src/platforms/instagram/ensure-auth.js.map +1 -1
- package/dist/src/platforms/instagram/index.d.ts +1 -0
- package/dist/src/platforms/instagram/index.d.ts.map +1 -1
- package/dist/src/platforms/instagram/index.js +1 -0
- package/dist/src/platforms/instagram/index.js.map +1 -1
- package/dist/src/platforms/instagram/token-extractor.d.ts +44 -0
- package/dist/src/platforms/instagram/token-extractor.d.ts.map +1 -0
- package/dist/src/platforms/instagram/token-extractor.js +407 -0
- package/dist/src/platforms/instagram/token-extractor.js.map +1 -0
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +2 -1
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/auth.js +14 -13
- package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.js +2 -1
- package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
- package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/line/commands/auth.js +6 -5
- package/dist/src/platforms/line/commands/auth.js.map +1 -1
- package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/slack/commands/auth.js +11 -10
- package/dist/src/platforms/slack/commands/auth.js.map +1 -1
- package/dist/src/platforms/slack/token-extractor.d.ts +9 -0
- package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/slack/token-extractor.js +300 -23
- package/dist/src/platforms/slack/token-extractor.js.map +1 -1
- package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/auth.js +9 -8
- package/dist/src/platforms/teams/commands/auth.js.map +1 -1
- package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/teams/ensure-auth.js +2 -1
- package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
- package/dist/src/platforms/teams/token-extractor.d.ts +5 -0
- package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
- package/dist/src/platforms/teams/token-extractor.js +161 -29
- package/dist/src/platforms/teams/token-extractor.js.map +1 -1
- package/dist/src/platforms/telegram/client.d.ts.map +1 -1
- package/dist/src/platforms/telegram/client.js +25 -7
- package/dist/src/platforms/telegram/client.js.map +1 -1
- package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/telegram/commands/auth.js +6 -5
- package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/client.d.ts +10 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +124 -0
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/auth.d.ts +4 -0
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/auth.js +46 -4
- package/dist/src/platforms/webex/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/credential-manager.js +1 -1
- package/dist/src/platforms/webex/credential-manager.js.map +1 -1
- package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/ensure-auth.js +21 -5
- package/dist/src/platforms/webex/ensure-auth.js.map +1 -1
- package/dist/src/platforms/webex/index.d.ts +2 -0
- package/dist/src/platforms/webex/index.d.ts.map +1 -1
- package/dist/src/platforms/webex/index.js +1 -0
- package/dist/src/platforms/webex/index.js.map +1 -1
- package/dist/src/platforms/webex/token-extractor.d.ts +28 -0
- package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -0
- package/dist/src/platforms/webex/token-extractor.js +344 -0
- package/dist/src/platforms/webex/token-extractor.js.map +1 -0
- package/dist/src/platforms/webex/types.d.ts +4 -1
- package/dist/src/platforms/webex/types.d.ts.map +1 -1
- package/dist/src/platforms/webex/types.js +2 -1
- package/dist/src/platforms/webex/types.js.map +1 -1
- package/dist/src/platforms/whatsapp/client.d.ts.map +1 -1
- package/dist/src/platforms/whatsapp/client.js +6 -2
- package/dist/src/platforms/whatsapp/client.js.map +1 -1
- package/dist/src/shared/utils/derived-key-cache.d.ts +1 -1
- package/dist/src/shared/utils/derived-key-cache.d.ts.map +1 -1
- package/dist/src/shared/utils/error-handler.d.ts +1 -1
- package/dist/src/shared/utils/error-handler.d.ts.map +1 -1
- package/dist/src/shared/utils/error-handler.js +3 -2
- package/dist/src/shared/utils/error-handler.js.map +1 -1
- package/dist/src/shared/utils/stderr.d.ts +5 -0
- package/dist/src/shared/utils/stderr.d.ts.map +1 -0
- package/dist/src/shared/utils/stderr.js +18 -0
- package/dist/src/shared/utils/stderr.js.map +1 -0
- package/docs/content/docs/cli/channeltalk.mdx +7 -7
- package/docs/content/docs/cli/discord.mdx +3 -3
- package/docs/content/docs/cli/instagram.mdx +28 -6
- package/docs/content/docs/cli/slack.mdx +2 -2
- package/docs/content/docs/cli/teams.mdx +6 -4
- package/docs/content/docs/cli/webex.mdx +30 -11
- package/e2e/README.md +132 -8
- package/e2e/channeltalk.e2e.test.ts +2 -7
- package/e2e/channeltalkbot.e2e.test.ts +2 -6
- package/e2e/config.ts +172 -10
- package/e2e/helpers.ts +7 -0
- package/e2e/instagram.e2e.test.ts +97 -0
- package/e2e/kakaotalk.e2e.test.ts +74 -0
- package/e2e/line.e2e.test.ts +92 -0
- package/e2e/teams.e2e.test.ts +46 -1
- package/e2e/telegram.e2e.test.ts +84 -0
- package/e2e/webex.e2e.test.ts +190 -0
- package/e2e/whatsapp.e2e.test.ts +90 -0
- package/e2e/whatsappbot.e2e.test.ts +78 -0
- package/package.json +2 -2
- package/skills/agent-channeltalk/SKILL.md +9 -9
- package/skills/agent-channeltalk/references/authentication.md +21 -18
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +5 -5
- package/skills/agent-discord/references/authentication.md +8 -8
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +51 -9
- package/skills/agent-instagram/references/authentication.md +35 -3
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +5 -5
- package/skills/agent-slack/references/authentication.md +8 -8
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +6 -6
- package/skills/agent-teams/references/authentication.md +8 -8
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +35 -15
- package/skills/agent-webex/references/authentication.md +62 -9
- package/skills/agent-webex/references/common-patterns.md +6 -3
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/channeltalk/commands/auth.test.ts +5 -5
- package/src/platforms/channeltalk/commands/auth.ts +38 -32
- package/src/platforms/channeltalk/ensure-auth.test.ts +6 -6
- package/src/platforms/channeltalk/ensure-auth.ts +6 -6
- package/src/platforms/channeltalk/token-extractor.test.ts +182 -15
- package/src/platforms/channeltalk/token-extractor.ts +344 -30
- package/src/platforms/discord/commands/auth.test.ts +3 -3
- package/src/platforms/discord/commands/auth.ts +58 -54
- package/src/platforms/discord/ensure-auth.test.ts +3 -3
- package/src/platforms/discord/ensure-auth.ts +3 -3
- package/src/platforms/discord/token-extractor.test.ts +199 -27
- package/src/platforms/discord/token-extractor.ts +190 -17
- package/src/platforms/instagram/client.ts +2 -2
- package/src/platforms/instagram/commands/auth.ts +133 -14
- package/src/platforms/instagram/ensure-auth.ts +63 -12
- package/src/platforms/instagram/index.ts +1 -0
- package/src/platforms/instagram/token-extractor.test.ts +424 -0
- package/src/platforms/instagram/token-extractor.ts +478 -0
- package/src/platforms/kakaotalk/client.ts +3 -1
- package/src/platforms/kakaotalk/commands/auth.ts +14 -13
- package/src/platforms/kakaotalk/protocol/connection.ts +3 -1
- package/src/platforms/line/commands/auth.ts +7 -6
- package/src/platforms/slack/cli.test.ts +6 -5
- package/src/platforms/slack/commands/auth.test.ts +11 -7
- package/src/platforms/slack/commands/auth.ts +11 -10
- package/src/platforms/slack/token-extractor.test.ts +98 -1
- package/src/platforms/slack/token-extractor.ts +338 -26
- package/src/platforms/teams/commands/auth.ts +9 -8
- package/src/platforms/teams/ensure-auth.ts +3 -1
- package/src/platforms/teams/token-extractor.test.ts +136 -17
- package/src/platforms/teams/token-extractor.ts +182 -31
- package/src/platforms/telegram/client.test.ts +134 -0
- package/src/platforms/telegram/client.ts +27 -6
- package/src/platforms/telegram/commands/auth.ts +6 -5
- package/src/platforms/webex/client.test.ts +314 -0
- package/src/platforms/webex/client.ts +158 -0
- package/src/platforms/webex/commands/auth.ts +67 -4
- package/src/platforms/webex/commands/member.test.ts +10 -1
- package/src/platforms/webex/commands/message.test.ts +9 -5
- package/src/platforms/webex/commands/snapshot.test.ts +13 -4
- package/src/platforms/webex/commands/space.test.ts +12 -2
- package/src/platforms/webex/credential-manager.ts +1 -1
- package/src/platforms/webex/ensure-auth.test.ts +4 -0
- package/src/platforms/webex/ensure-auth.ts +23 -4
- package/src/platforms/webex/index.ts +2 -0
- package/src/platforms/webex/token-extractor.test.ts +327 -0
- package/src/platforms/webex/token-extractor.ts +393 -0
- package/src/platforms/webex/types.ts +4 -2
- package/src/platforms/whatsapp/client.ts +11 -7
- package/src/shared/utils/derived-key-cache.ts +1 -1
- package/src/shared/utils/error-handler.ts +4 -2
- package/src/shared/utils/stderr.ts +22 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import { createDecipheriv, pbkdf2Sync } from 'node:crypto'
|
|
3
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { homedir, tmpdir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { DerivedKeyCache } from '@/shared/utils/derived-key-cache'
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
|
|
12
|
+
export interface ExtractedInstagramCookies {
|
|
13
|
+
sessionid: string
|
|
14
|
+
ds_user_id: string
|
|
15
|
+
csrftoken: string
|
|
16
|
+
mid?: string
|
|
17
|
+
ig_did?: string
|
|
18
|
+
rur?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface BrowserConfig {
|
|
22
|
+
name: string
|
|
23
|
+
darwin: string
|
|
24
|
+
linux: string
|
|
25
|
+
win32: string
|
|
26
|
+
localStateDarwin?: string
|
|
27
|
+
localStateLinux?: string
|
|
28
|
+
localStateWin32?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface KeychainVariant {
|
|
32
|
+
service: string
|
|
33
|
+
account: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const BROWSERS: BrowserConfig[] = [
|
|
37
|
+
{
|
|
38
|
+
name: 'Chrome',
|
|
39
|
+
darwin: join('Google', 'Chrome'),
|
|
40
|
+
linux: 'google-chrome',
|
|
41
|
+
win32: join('Google', 'Chrome', 'User Data'),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'Chrome Canary',
|
|
45
|
+
darwin: join('Google', 'Chrome Canary'),
|
|
46
|
+
linux: 'google-chrome-unstable',
|
|
47
|
+
win32: join('Google', 'Chrome SxS', 'User Data'),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'Edge',
|
|
51
|
+
darwin: 'Microsoft Edge',
|
|
52
|
+
linux: 'microsoft-edge',
|
|
53
|
+
win32: join('Microsoft', 'Edge', 'User Data'),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'Arc',
|
|
57
|
+
darwin: join('Arc', 'User Data'),
|
|
58
|
+
linux: '',
|
|
59
|
+
win32: join('Arc', 'User Data'),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'Brave',
|
|
63
|
+
darwin: join('BraveSoftware', 'Brave-Browser'),
|
|
64
|
+
linux: join('BraveSoftware', 'Brave-Browser'),
|
|
65
|
+
win32: join('BraveSoftware', 'Brave-Browser', 'User Data'),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'Vivaldi',
|
|
69
|
+
darwin: 'Vivaldi',
|
|
70
|
+
linux: 'vivaldi',
|
|
71
|
+
win32: join('Vivaldi', 'User Data'),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'Chromium',
|
|
75
|
+
darwin: 'Chromium',
|
|
76
|
+
linux: 'chromium',
|
|
77
|
+
win32: join('Chromium', 'User Data'),
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const INSTAGRAM_HOST_KEYS = ['.instagram.com', 'www.instagram.com', 'i.instagram.com']
|
|
82
|
+
const INSTAGRAM_COOKIE_NAMES = ['sessionid', 'ds_user_id', 'csrftoken', 'mid', 'ig_did', 'rur']
|
|
83
|
+
|
|
84
|
+
export class InstagramTokenExtractor {
|
|
85
|
+
private platform: NodeJS.Platform
|
|
86
|
+
private debugLog: ((message: string) => void) | null
|
|
87
|
+
private cachedKey: Buffer | null = null
|
|
88
|
+
|
|
89
|
+
constructor(
|
|
90
|
+
platform?: NodeJS.Platform,
|
|
91
|
+
debugLog?: (message: string) => void,
|
|
92
|
+
_keyCache?: DerivedKeyCache,
|
|
93
|
+
) {
|
|
94
|
+
this.platform = platform ?? process.platform
|
|
95
|
+
this.debugLog = debugLog ?? null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private debug(message: string): void {
|
|
99
|
+
this.debugLog?.(message)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getBrowserCookiesPaths(): string[] {
|
|
103
|
+
const paths: string[] = []
|
|
104
|
+
|
|
105
|
+
for (const browser of BROWSERS) {
|
|
106
|
+
const browserBase = this.getBrowserBasePath(browser)
|
|
107
|
+
if (!browserBase) continue
|
|
108
|
+
|
|
109
|
+
const profileDirs = this.discoverProfileDirs(browserBase)
|
|
110
|
+
for (const profileDir of profileDirs) {
|
|
111
|
+
paths.push(join(profileDir, 'Cookies'))
|
|
112
|
+
paths.push(join(profileDir, 'Network', 'Cookies'))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return paths
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getLocalStatePaths(): string[] {
|
|
120
|
+
const paths: string[] = []
|
|
121
|
+
|
|
122
|
+
for (const browser of BROWSERS) {
|
|
123
|
+
const browserBase = this.getBrowserBasePath(browser)
|
|
124
|
+
if (!browserBase) continue
|
|
125
|
+
|
|
126
|
+
paths.push(join(browserBase, 'Local State'))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return paths
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private getBrowserBasePath(browser: BrowserConfig): string | null {
|
|
133
|
+
let relative: string
|
|
134
|
+
|
|
135
|
+
switch (this.platform) {
|
|
136
|
+
case 'darwin':
|
|
137
|
+
relative = browser.darwin
|
|
138
|
+
if (!relative) return null
|
|
139
|
+
return join(homedir(), 'Library', 'Application Support', relative)
|
|
140
|
+
case 'linux':
|
|
141
|
+
relative = browser.linux
|
|
142
|
+
if (!relative) return null
|
|
143
|
+
return join(homedir(), '.config', relative)
|
|
144
|
+
case 'win32':
|
|
145
|
+
relative = browser.win32
|
|
146
|
+
if (!relative) return null
|
|
147
|
+
return join(
|
|
148
|
+
process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'),
|
|
149
|
+
relative,
|
|
150
|
+
)
|
|
151
|
+
default:
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private discoverProfileDirs(browserBase: string): string[] {
|
|
157
|
+
const dirs: string[] = []
|
|
158
|
+
|
|
159
|
+
dirs.push(join(browserBase, 'Default'))
|
|
160
|
+
|
|
161
|
+
if (!existsSync(browserBase)) return dirs
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const entries = readdirSync(browserBase, { withFileTypes: true })
|
|
165
|
+
for (const entry of entries) {
|
|
166
|
+
if (!entry.isDirectory()) continue
|
|
167
|
+
if (!/^Profile \d+$/i.test(entry.name)) continue
|
|
168
|
+
dirs.push(join(browserBase, entry.name))
|
|
169
|
+
}
|
|
170
|
+
} catch {}
|
|
171
|
+
|
|
172
|
+
return dirs
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
getKeychainVariants(): KeychainVariant[] {
|
|
176
|
+
return [
|
|
177
|
+
{ service: 'Chrome Safe Storage', account: 'Chrome' },
|
|
178
|
+
{ service: 'Chrome Canary Safe Storage', account: 'Chrome Canary' },
|
|
179
|
+
{ service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' },
|
|
180
|
+
{ service: 'Arc Safe Storage', account: 'Arc' },
|
|
181
|
+
{ service: 'Brave Safe Storage', account: 'Brave' },
|
|
182
|
+
{ service: 'Vivaldi Safe Storage', account: 'Vivaldi' },
|
|
183
|
+
{ service: 'Chromium Safe Storage', account: 'Chromium' },
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
isEncryptedValue(value: Buffer): boolean {
|
|
188
|
+
if (!value || value.length < 4) return false
|
|
189
|
+
const prefix = value.subarray(0, 3).toString('utf8')
|
|
190
|
+
return prefix === 'v10' || prefix === 'v11'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
isValidSessionId(sessionid: string): boolean {
|
|
194
|
+
if (!sessionid || sessionid.length === 0) return false
|
|
195
|
+
return sessionid.length >= 20
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async extract(): Promise<ExtractedInstagramCookies[]> {
|
|
199
|
+
const results: ExtractedInstagramCookies[] = []
|
|
200
|
+
const seenUsers = new Set<string>()
|
|
201
|
+
const cookiePaths = this.getBrowserCookiesPaths()
|
|
202
|
+
|
|
203
|
+
for (const cookiePath of cookiePaths) {
|
|
204
|
+
if (!existsSync(cookiePath)) continue
|
|
205
|
+
|
|
206
|
+
this.debug(`Scanning: ${cookiePath}`)
|
|
207
|
+
const cookies = await this.copyAndExtract(cookiePath)
|
|
208
|
+
if (cookies && !seenUsers.has(cookies.ds_user_id)) {
|
|
209
|
+
this.debug(`Found Instagram cookies in: ${cookiePath}`)
|
|
210
|
+
seenUsers.add(cookies.ds_user_id)
|
|
211
|
+
results.push(cookies)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (results.length === 0) {
|
|
216
|
+
this.debug('No Instagram cookies found in any browser profile')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return results
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async copyAndExtract(dbPath: string): Promise<ExtractedInstagramCookies | null> {
|
|
223
|
+
const tempPath = join(tmpdir(), `instagram-cookies-${Date.now()}`)
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
this.copyDatabaseToTemp(dbPath, tempPath)
|
|
227
|
+
const cookies = await this.extractFromSQLite(tempPath, dbPath)
|
|
228
|
+
this.cleanupTempFile(tempPath)
|
|
229
|
+
return cookies
|
|
230
|
+
} catch {
|
|
231
|
+
this.cleanupTempFile(tempPath)
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private copyDatabaseToTemp(sourcePath: string, destPath: string): string {
|
|
237
|
+
copyFileSync(sourcePath, destPath)
|
|
238
|
+
return destPath
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private cleanupTempFile(tempPath: string): void {
|
|
242
|
+
try {
|
|
243
|
+
if (existsSync(tempPath)) {
|
|
244
|
+
unlinkSync(tempPath)
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
// Ignore cleanup errors
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async extractFromSQLite(
|
|
252
|
+
dbPath: string,
|
|
253
|
+
originalPath: string,
|
|
254
|
+
): Promise<ExtractedInstagramCookies | null> {
|
|
255
|
+
try {
|
|
256
|
+
const placeholders = INSTAGRAM_HOST_KEYS.map(() => '?').join(', ')
|
|
257
|
+
const sql = `
|
|
258
|
+
SELECT name, value, encrypted_value
|
|
259
|
+
FROM cookies
|
|
260
|
+
WHERE host_key IN (${placeholders})
|
|
261
|
+
`
|
|
262
|
+
|
|
263
|
+
type CookieRow = { name: string; value?: string; encrypted_value?: Uint8Array | Buffer }
|
|
264
|
+
|
|
265
|
+
let rows: CookieRow[]
|
|
266
|
+
if (typeof globalThis.Bun !== 'undefined') {
|
|
267
|
+
const { Database } = require('bun:sqlite')
|
|
268
|
+
const db = new Database(dbPath, { readonly: true })
|
|
269
|
+
rows = db.query(sql).all(...INSTAGRAM_HOST_KEYS) as CookieRow[]
|
|
270
|
+
db.close()
|
|
271
|
+
} else {
|
|
272
|
+
const Database = require('better-sqlite3')
|
|
273
|
+
const db = new Database(dbPath, { readonly: true })
|
|
274
|
+
rows = db.prepare(sql).all(...INSTAGRAM_HOST_KEYS) as CookieRow[]
|
|
275
|
+
db.close()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const cookieMap: Record<string, string> = {}
|
|
279
|
+
for (const row of rows) {
|
|
280
|
+
if (!INSTAGRAM_COOKIE_NAMES.includes(row.name)) continue
|
|
281
|
+
|
|
282
|
+
let value = ''
|
|
283
|
+
if (row.encrypted_value && row.encrypted_value.length > 0) {
|
|
284
|
+
const encBuf = Buffer.from(row.encrypted_value)
|
|
285
|
+
if (this.isEncryptedValue(encBuf)) {
|
|
286
|
+
const decrypted = this.decryptCookie(encBuf, originalPath)
|
|
287
|
+
if (decrypted) {
|
|
288
|
+
value = decrypted
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
value = encBuf.toString('utf8')
|
|
292
|
+
}
|
|
293
|
+
} else if (row.value) {
|
|
294
|
+
value = row.value
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (value && !cookieMap[row.name]) {
|
|
298
|
+
cookieMap[row.name] = value
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!cookieMap['sessionid'] || !cookieMap['ds_user_id'] || !cookieMap['csrftoken']) {
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!this.isValidSessionId(cookieMap['sessionid'])) {
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const result: ExtractedInstagramCookies = {
|
|
311
|
+
sessionid: cookieMap['sessionid'],
|
|
312
|
+
ds_user_id: cookieMap['ds_user_id'],
|
|
313
|
+
csrftoken: cookieMap['csrftoken'],
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (cookieMap['mid']) result.mid = cookieMap['mid']
|
|
317
|
+
if (cookieMap['ig_did']) result.ig_did = cookieMap['ig_did']
|
|
318
|
+
if (cookieMap['rur']) result.rur = cookieMap['rur']
|
|
319
|
+
|
|
320
|
+
return result
|
|
321
|
+
} catch {
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private decryptCookie(encryptedValue: Buffer, dbPath: string): string | null {
|
|
327
|
+
if (!this.isEncryptedValue(encryptedValue)) {
|
|
328
|
+
return encryptedValue.toString('utf8')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (this.platform === 'win32') {
|
|
332
|
+
return this.decryptWindowsCookie(encryptedValue, dbPath)
|
|
333
|
+
} else if (this.platform === 'darwin') {
|
|
334
|
+
return this.decryptMacCookie(encryptedValue)
|
|
335
|
+
} else if (this.platform === 'linux') {
|
|
336
|
+
return this.decryptLinuxCookie(encryptedValue)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private decryptWindowsCookie(encryptedData: Buffer, dbPath: string): string | null {
|
|
343
|
+
try {
|
|
344
|
+
const localStatePath = this.findLocalStateForCookiePath(dbPath)
|
|
345
|
+
if (!localStatePath || !existsSync(localStatePath)) return null
|
|
346
|
+
|
|
347
|
+
const localState = JSON.parse(readFileSync(localStatePath, 'utf8'))
|
|
348
|
+
const encryptedKey = Buffer.from(localState.os_crypt.encrypted_key, 'base64')
|
|
349
|
+
const dpapiBlobKey = encryptedKey.subarray(5)
|
|
350
|
+
const masterKey = this.decryptDPAPI(dpapiBlobKey)
|
|
351
|
+
if (!masterKey) return null
|
|
352
|
+
|
|
353
|
+
return this.decryptAESGCM(encryptedData, masterKey)
|
|
354
|
+
} catch {
|
|
355
|
+
return null
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private findLocalStateForCookiePath(cookiePath: string): string | null {
|
|
360
|
+
const parts = cookiePath.split(/[/\\]/)
|
|
361
|
+
for (let levels = 2; levels <= 4; levels++) {
|
|
362
|
+
if (parts.length < levels) break
|
|
363
|
+
const base = parts.slice(0, parts.length - levels).join('/')
|
|
364
|
+
const candidate = join(base, 'Local State')
|
|
365
|
+
if (existsSync(candidate)) return candidate
|
|
366
|
+
}
|
|
367
|
+
return null
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private decryptDPAPI(encryptedBlob: Buffer): Buffer | null {
|
|
371
|
+
try {
|
|
372
|
+
const b64 = encryptedBlob.toString('base64')
|
|
373
|
+
const psScript = `
|
|
374
|
+
Add-Type -AssemblyName System.Security
|
|
375
|
+
$bytes = [Convert]::FromBase64String('${b64}')
|
|
376
|
+
$decrypted = [Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, 'CurrentUser')
|
|
377
|
+
[Convert]::ToBase64String($decrypted)
|
|
378
|
+
`.replace(/\n/g, ' ')
|
|
379
|
+
|
|
380
|
+
const result = execSync(`powershell -Command "${psScript}"`, { encoding: 'utf8' })
|
|
381
|
+
return Buffer.from(result.trim(), 'base64')
|
|
382
|
+
} catch {
|
|
383
|
+
return null
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private decryptMacCookie(encryptedData: Buffer): string | null {
|
|
388
|
+
if (this.cachedKey) {
|
|
389
|
+
const decrypted = this.decryptAESCBC(encryptedData, this.cachedKey)
|
|
390
|
+
if (decrypted) return decrypted
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const variant of this.getKeychainVariants()) {
|
|
394
|
+
const password = this.execSecurityCommand(variant.service, variant.account)
|
|
395
|
+
if (!password) continue
|
|
396
|
+
|
|
397
|
+
const key = pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1')
|
|
398
|
+
const decrypted = this.decryptAESCBC(encryptedData, key)
|
|
399
|
+
if (decrypted) {
|
|
400
|
+
this.cachedKey = key
|
|
401
|
+
return decrypted
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return null
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private decryptLinuxCookie(encryptedData: Buffer): string | null {
|
|
409
|
+
const key = pbkdf2Sync('peanuts', 'saltysalt', 1, 16, 'sha1')
|
|
410
|
+
return this.decryptAESCBC(encryptedData, key)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private getKeychainPassword(): string | null {
|
|
414
|
+
for (const variant of this.getKeychainVariants()) {
|
|
415
|
+
const password = this.execSecurityCommand(variant.service, variant.account)
|
|
416
|
+
if (password) return password
|
|
417
|
+
}
|
|
418
|
+
return null
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private execSecurityCommand(service: string, account: string): string | null {
|
|
422
|
+
try {
|
|
423
|
+
const safeService = service.replace(/"/g, '\\"')
|
|
424
|
+
const safeAccount = account.replace(/"/g, '\\"')
|
|
425
|
+
const result = execSync(
|
|
426
|
+
`security find-generic-password -s "${safeService}" -a "${safeAccount}" -w 2>/dev/null`,
|
|
427
|
+
{ encoding: 'utf8' },
|
|
428
|
+
)
|
|
429
|
+
return result.trim()
|
|
430
|
+
} catch {
|
|
431
|
+
return null
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private decryptAESCBC(encryptedData: Buffer, key: Buffer): string | null {
|
|
436
|
+
try {
|
|
437
|
+
const ciphertext = encryptedData.subarray(3)
|
|
438
|
+
const iv = Buffer.alloc(16, 0x20)
|
|
439
|
+
|
|
440
|
+
const decipher = createDecipheriv('aes-128-cbc', key, iv)
|
|
441
|
+
decipher.setAutoPadding(true)
|
|
442
|
+
|
|
443
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
444
|
+
|
|
445
|
+
// Chromium v130+ prepends a 32-byte integrity hash before the actual cookie value.
|
|
446
|
+
// Detect by checking if the first bytes contain non-printable characters.
|
|
447
|
+
if (decrypted.length > 32) {
|
|
448
|
+
const hasNonPrintablePrefix = decrypted.subarray(0, 32).some((b) => b < 0x20 || b > 0x7e)
|
|
449
|
+
if (hasNonPrintablePrefix) {
|
|
450
|
+
return decrypted.subarray(32).toString('utf8')
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return decrypted.toString('utf8')
|
|
455
|
+
} catch {
|
|
456
|
+
return null
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private decryptAESGCM(encryptedData: Buffer, key: Buffer): string | null {
|
|
461
|
+
try {
|
|
462
|
+
// Format: v10 (3 bytes) + IV (12 bytes) + ciphertext + auth tag (16 bytes)
|
|
463
|
+
if (encryptedData.length < 3 + 12 + 16) return null
|
|
464
|
+
|
|
465
|
+
const iv = encryptedData.subarray(3, 15)
|
|
466
|
+
const authTag = encryptedData.subarray(-16)
|
|
467
|
+
const ciphertext = encryptedData.subarray(15, -16)
|
|
468
|
+
|
|
469
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
|
470
|
+
decipher.setAuthTag(authTag)
|
|
471
|
+
|
|
472
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
473
|
+
return decrypted.toString('utf8')
|
|
474
|
+
} catch {
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Long } from 'bson'
|
|
2
2
|
|
|
3
|
+
import { warn } from '@/shared/utils/stderr'
|
|
4
|
+
|
|
3
5
|
import { LocoSession } from './protocol/session'
|
|
4
6
|
import type { ChatListResponse, LoginListResponse } from './protocol/types'
|
|
5
7
|
import type { KakaoChat, KakaoMessage, KakaoSendResult } from './types'
|
|
@@ -279,7 +281,7 @@ export class KakaoTalkClient {
|
|
|
279
281
|
cur = maxLog
|
|
280
282
|
}
|
|
281
283
|
if (!reachedEnd) {
|
|
282
|
-
|
|
284
|
+
warn(`[agent-kakaotalk] Warning: message fetch capped at ${MAX_PAGES} pages. Results may be incomplete.`)
|
|
283
285
|
}
|
|
284
286
|
|
|
285
287
|
allMessages.sort((a, b) => (a.sendAt as number) - (b.sendAt as number))
|
|
@@ -4,6 +4,7 @@ import { Command } from 'commander'
|
|
|
4
4
|
|
|
5
5
|
import { handleError } from '@/shared/utils/error-handler'
|
|
6
6
|
import { formatOutput } from '@/shared/utils/output'
|
|
7
|
+
import { info, error, debug } from '@/shared/utils/stderr'
|
|
7
8
|
|
|
8
9
|
import { generateDeviceUuid, loginFlow } from '../auth/kakao-login'
|
|
9
10
|
import { CredentialManager } from '../credential-manager'
|
|
@@ -231,15 +232,15 @@ async function loginAction(options: KakaoAuthOptions): Promise<void> {
|
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
if (email && interactive) {
|
|
234
|
-
|
|
235
|
+
info(` Using cached credentials for ${email}`)
|
|
235
236
|
}
|
|
236
237
|
if (isHashedPassword && !password) {
|
|
237
238
|
const passwordPrompt = email ? `Password for ${email}` : 'Password'
|
|
238
239
|
if (interactive) {
|
|
239
|
-
|
|
240
|
+
info(` One-time setup: password is needed to register this device.`)
|
|
240
241
|
password = await promptHidden(passwordPrompt)
|
|
241
242
|
} else if (hasTTY()) {
|
|
242
|
-
|
|
243
|
+
info(` One-time setup: password is needed to register this device.`)
|
|
243
244
|
try { password = await promptHiddenTTY(passwordPrompt) } catch { /* /dev/tty open failed */ }
|
|
244
245
|
}
|
|
245
246
|
if (!password) {
|
|
@@ -262,7 +263,7 @@ async function loginAction(options: KakaoAuthOptions): Promise<void> {
|
|
|
262
263
|
return
|
|
263
264
|
}
|
|
264
265
|
email = await promptText('KakaoTalk email')
|
|
265
|
-
if (!email) {
|
|
266
|
+
if (!email) { error('Email is required.'); process.exit(1) }
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
if (!password) {
|
|
@@ -271,7 +272,7 @@ async function loginAction(options: KakaoAuthOptions): Promise<void> {
|
|
|
271
272
|
return
|
|
272
273
|
}
|
|
273
274
|
password = await promptHidden('Password')
|
|
274
|
-
if (!password) {
|
|
275
|
+
if (!password) { error('Password is required.'); process.exit(1) }
|
|
275
276
|
}
|
|
276
277
|
|
|
277
278
|
const existing = await credManager.getAccount()
|
|
@@ -280,14 +281,14 @@ async function loginAction(options: KakaoAuthOptions): Promise<void> {
|
|
|
280
281
|
|
|
281
282
|
const onPasscodeDisplay = (code: string) => {
|
|
282
283
|
if (interactive) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
info('')
|
|
285
|
+
info(` Enter this code on your phone: ${code}`)
|
|
286
|
+
info(' Waiting for confirmation...')
|
|
287
|
+
info('')
|
|
287
288
|
}
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
const debugLog = options.debug ? (msg: string) =>
|
|
291
|
+
const debugLog = options.debug ? (msg: string) => debug(`[debug] ${msg}`) : undefined
|
|
291
292
|
|
|
292
293
|
const result = await loginFlow({
|
|
293
294
|
email,
|
|
@@ -380,7 +381,7 @@ async function extractAction(options: {
|
|
|
380
381
|
if (options.unsafelyShowSecrets) {
|
|
381
382
|
options.debug = true
|
|
382
383
|
}
|
|
383
|
-
const debugLog = options.debug ? (msg: string) =>
|
|
384
|
+
const debugLog = options.debug ? (msg: string) => debug(`[debug] ${msg}`) : undefined
|
|
384
385
|
const extractor = new KakaoTokenExtractor(undefined, debugLog)
|
|
385
386
|
|
|
386
387
|
const token = await extractor.extract()
|
|
@@ -402,8 +403,8 @@ async function extractAction(options: {
|
|
|
402
403
|
const display = options.unsafelyShowSecrets
|
|
403
404
|
? token.oauth_token
|
|
404
405
|
: `${token.oauth_token.substring(0, 12)}...`
|
|
405
|
-
|
|
406
|
-
|
|
406
|
+
debug(`[debug] oauth_token: ${display}`)
|
|
407
|
+
debug(`[debug] user_id: ${token.user_id}`)
|
|
407
408
|
}
|
|
408
409
|
|
|
409
410
|
const credManager = new CredentialManager()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Socket, connect as netConnect } from 'node:net'
|
|
2
2
|
import { connect as tlsConnect } from 'node:tls'
|
|
3
3
|
|
|
4
|
+
import { debug } from '@/shared/utils/stderr'
|
|
5
|
+
|
|
4
6
|
import { LocoCrypto } from './crypto'
|
|
5
7
|
import { decodePacket, encodePacket } from './packet'
|
|
6
8
|
import type { LocoPacket } from './types'
|
|
@@ -114,7 +116,7 @@ export class LocoConnection {
|
|
|
114
116
|
try {
|
|
115
117
|
decrypted = this.crypto.decrypt(encryptedBody)
|
|
116
118
|
} catch (err) {
|
|
117
|
-
|
|
119
|
+
debug(`[loco] decrypt failed: ${(err as Error).message}`)
|
|
118
120
|
continue
|
|
119
121
|
}
|
|
120
122
|
|
|
@@ -8,6 +8,7 @@ import QRCode from 'qrcode'
|
|
|
8
8
|
|
|
9
9
|
import { handleError } from '@/shared/utils/error-handler'
|
|
10
10
|
import { formatOutput } from '@/shared/utils/output'
|
|
11
|
+
import { info } from '@/shared/utils/stderr'
|
|
11
12
|
|
|
12
13
|
import { LineClient } from '../client'
|
|
13
14
|
import { LineCredentialManager } from '../credential-manager'
|
|
@@ -85,9 +86,9 @@ async function loginAction(options: {
|
|
|
85
86
|
email: options.email,
|
|
86
87
|
password: options.password,
|
|
87
88
|
device,
|
|
88
|
-
|
|
89
|
+
onPincode: (pin) => {
|
|
89
90
|
if (interactive) {
|
|
90
|
-
|
|
91
|
+
info(`\nEnter this PIN in the LINE mobile app: ${pin}\n`)
|
|
91
92
|
}
|
|
92
93
|
},
|
|
93
94
|
})
|
|
@@ -104,14 +105,14 @@ async function loginAction(options: {
|
|
|
104
105
|
await openQRInBrowser(url).catch(() => {})
|
|
105
106
|
try {
|
|
106
107
|
const qrAscii = await QRCode.toString(url, { type: 'terminal', small: true })
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
info('\nScan this QR code with the LINE mobile app:\n')
|
|
109
|
+
info(qrAscii)
|
|
109
110
|
} catch {
|
|
110
|
-
|
|
111
|
+
info(`\nOpen the QR code in the browser window, or scan this URL:\n${url}\n`)
|
|
111
112
|
}
|
|
112
113
|
},
|
|
113
114
|
onPincode: (pin) => {
|
|
114
|
-
|
|
115
|
+
info(`\nEnter this PIN in the LINE mobile app: ${pin}\n`)
|
|
115
116
|
},
|
|
116
117
|
})
|
|
117
118
|
console.log(formatOutput(result, options.pretty))
|
|
@@ -39,12 +39,13 @@ describe('CLI Framework', () => {
|
|
|
39
39
|
describe('handleError utility', () => {
|
|
40
40
|
test('logs error as JSON and exits', () => {
|
|
41
41
|
const originalExit = process.exit
|
|
42
|
-
const
|
|
42
|
+
const originalWrite = process.stderr.write
|
|
43
43
|
let capturedOutput = ''
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
capturedOutput
|
|
47
|
-
|
|
45
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
46
|
+
capturedOutput += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
|
|
47
|
+
return true
|
|
48
|
+
}) as typeof process.stderr.write
|
|
48
49
|
process.exit = (() => {
|
|
49
50
|
throw new Error('EXIT_CALLED')
|
|
50
51
|
}) as never
|
|
@@ -58,7 +59,7 @@ describe('CLI Framework', () => {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
process.stderr.write = originalWrite
|
|
62
63
|
process.exit = originalExit
|
|
63
64
|
})
|
|
64
65
|
})
|