Compare commits

..
Author SHA1 Message Date
Brandon Thomas cb1bea9d0a remove the desktop app from the repo 2026-09-12 12:23:46 -04:00
Brandon Thomas 889f6c3a4c Merge pull request #6 from storytold/bt-readme-update
readme update
2026-09-12 11:45:33 -04:00
1520 changed files with 507 additions and 101087 deletions

No files matched your search

@@ -1,240 +0,0 @@
name: "Artcraft MacOS Publish"
on:
push:
branches:
- release
- artcraft-release
- artcraft-macos # Additional branch for MacOS testing
# This workflow creates Universal macOS builds with signing certificate support.
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-artcraft:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
# Universal macOS builds (arm64 and x86_64)
- platform: 'macos-latest-xlarge'
args: '--target universal-apple-darwin'
# Uncomment below for additional platforms
# - platform: 'ubuntu-22.04'
# args: ''
# - platform: 'windows-latest'
# args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest-xlarge' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: replace tauri config with mac-specific version
run: |
pushd ${{ github.workspace }}/crates/desktop/artcraft
# Use the mac-specific tauri conf for the build
# TODO(bt): Replace this with the platform-specific naming conventions
cp tauri-mac.conf.json tauri.conf.json
popd
- name: install frontend dependencies
run: |
# Navigate to the frontend directory to install dependencies
cd ./frontend
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci
echo "=== Version Information ==="
echo "Node version:"
node --version
echo "NPM version:"
npm --version
# Remove mismatched @nx packages and reinstall with correct versions
npm install --save-dev @nx/remix@21.1.1 @nx/js@21.1.1 @nx/react@21.1.1 @nx/vite@21.1.1 @nx/eslint@21.1.1
echo "=== Nx versions after adjustment ==="
npm list --depth=0 | grep nx
- name: import Apple Developer Certificate
if: matrix.platform == 'macos-latest-xlarge'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo "Starting certificate import process..."
# Create certificate file
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Debug: Check if certificate file exists and has content
echo "Certificate file info:"
ls -la certificate.p12
file certificate.p12
# Debug: Check certificate contents without password (just structure)
echo "Certificate structure (may show error if password protected):"
openssl pkcs12 -info -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -noout || echo "Could not read certificate structure"
# Debug: Try to list what's in the p12 file
echo "Attempting to list certificate contents:"
security cms -D -i certificate.p12 || echo "Not a CMS file, trying PKCS12..."
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents"
# Debug: Extract certificate details to verify type and expiration
echo "Extracting certificate details..."
# Extract just the certificate portion and examine it
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details"
# Also try to see what type of certificate this is
echo "Certificate type check:"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type"
# Debug: Check if private key exists
echo "Checking for private key..."
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found"
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
# Import certificate - ensure both certificate and private key are imported
echo "Importing certificate into keychain..."
# Import the p12 directly - this is the most reliable method
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild
# Alternative: Try with additional flags if the first import didn't create an identity
echo "Verifying import..."
if ! security find-identity -v build.keychain | grep -q "1 valid identities found"; then
echo "First import didn't create identity, trying with -A flag..."
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -A
fi
# Set trust settings for the certificate
echo "Setting trust settings..."
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Unlock the keychain again to ensure it's accessible
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Add the keychain to the search list
security list-keychains -d user -s build.keychain $(security list-keychains -d user | sed 's/^[[:space:]]*"//;s/"$//')
# List all certificates to debug
echo "Listing all keychains:"
security list-keychains
echo "Listing all certificates in build keychain:"
security find-certificate -a -p build.keychain || echo "No certificates found"
echo "Detailed certificate info:"
security find-certificate -c "Learning Machines" build.keychain || echo "Certificate 'Learning Machines' not found"
# Check certificate trust settings
echo "Checking certificate trust settings:"
security dump-trust-settings || echo "No custom trust settings"
# Try to find codesigning identities
echo "Finding all identities in keychain:"
security find-identity -v build.keychain || echo "No identities found"
echo "Finding codesigning identities:"
security find-identity -v -p codesigning build.keychain || echo "No codesigning identities found"
# Additional debug: Check if this is a valid Developer ID cert
echo "Checking for Developer ID certificates:"
security find-certificate -a build.keychain | grep -A 5 -B 5 "Developer ID" || echo "No Developer ID certificates found"
- name: verify certificate
if: matrix.platform == 'macos-latest-xlarge'
run: |
echo "=== Certificate Verification ==="
echo "1. Checking codesigning identities:"
security find-identity -v -p codesigning build.keychain || echo "No codesigning identities found"
echo -e "\n2. Checking all identities:"
security find-identity -v build.keychain || echo "No identities found"
echo -e "\n3. Checking certificates in keychain:"
security find-certificate -c "Learning Machines" build.keychain -p | openssl x509 -text -noout | grep -E "(Subject:|CN=)" || echo "Could not parse certificate"
# Set the correct signing identity
echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> $GITHUB_ENV
echo "Proceeding with identity: Developer ID Application: Learning Machines LLC (DJ6XS33FX8)"
# Also set the team ID
echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> $GITHUB_ENV
- name: Pre-build frontend to avoid Tauri build issues
run: |
# Build the frontend app from the correct directory
cd ./frontend
# Increase Node.js memory limit for the build
export NODE_OPTIONS="--max-old-space-size=8192"
# Use npx to ensure we use the local nx version
echo "Building with npx nx..."
npx nx run artcraft:build
echo "Checking build output..."
ls -la apps/artcraft/dist/ | head -10
# Update Tauri config to skip the build (since we already built)
cd ${{ github.workspace }}/crates/desktop/artcraft
cp tauri.conf.json tauri.conf.json.bak
# Set beforeBuildCommand to empty or a no-op command
if command -v jq &> /dev/null; then
jq '.build.beforeBuildCommand = ""' tauri.conf.json > tauri.conf.json.tmp
mv tauri.conf.json.tmp tauri.conf.json
else
node -e "const fs = require('fs'); const config = JSON.parse(fs.readFileSync('tauri.conf.json', 'utf8')); config.build.beforeBuildCommand = ''; fs.writeFileSync('tauri.conf.json', JSON.stringify(config, null, 2));"
fi
- name: build and publish
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_DEVELOPMENT_TEAM: ${{ env.APPLE_DEVELOPMENT_TEAM }}
# Use the SQLx query cache for SQLite
SQLX_OFFLINE: true
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "ArtCraft v__VERSION__"
releaseBody: "See the assets below to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
@@ -1,107 +0,0 @@
name: "Artcraft Windows Publish"
on:
push:
branches:
- release
- artcraft-release
- artcraft-windows # Additional branch for Windows testing
# This workflow creates Windows builds.
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-artcraft-windows:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
# Windows builds
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: install wreq dependencies
run: |
choco install cmake -y
choco install strawberryperl -y
choco install pkgconfiglite -y
choco install llvm -y
choco install nasm -y
shell: cmd
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install frontend dependencies
run: |
# Navigate to the frontend directory to install dependencies
cd ./frontend
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci
echo "=== Version Information ==="
echo "Node version:"
node --version
echo "NPM version:"
npm --version
# Remove mismatched @nx packages and reinstall with correct versions
npm install --save-dev @nx/remix@21.1.1 @nx/js@21.1.1 @nx/react@21.1.1 @nx/vite@21.1.1 @nx/eslint@21.1.1
echo "=== Nx versions after adjustment ==="
npm list --depth=0 | findstr nx
- name: Pre-build frontend to avoid Tauri build issues
run: |
# Build the frontend app from the correct directory
cd ./frontend
# Increase Node.js memory limit for the build
$env:NODE_OPTIONS="--max-old-space-size=8192"
# Use npx to ensure we use the local nx version
echo "Building with npx nx..."
npx nx run artcraft:build
echo "Checking build output..."
dir apps/artcraft/dist/ | Select-Object -First 10
# Update Tauri config to skip the build (since we already built)
cd ${{ github.workspace }}/crates/desktop/artcraft
Copy-Item tauri.conf.json tauri.conf.json.bak
# Set beforeBuildCommand to empty using PowerShell
$config = Get-Content tauri.conf.json | ConvertFrom-Json
$config.build.beforeBuildCommand = ""
$config | ConvertTo-Json -Depth 10 | Set-Content tauri.conf.json
- name: build and publish
uses: tauri-apps/tauri-action@v0
env:
# NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri
RUSTFLAGS: "-C target-feature=+crt-static"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Use the SQLx query cache for SQLite
SQLX_OFFLINE: true
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "ArtCraft v__VERSION__"
releaseBody: "Production build of ArtCraft. See the assets below to download and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
@@ -1,63 +0,0 @@
name: 'publish'
on:
push:
branches:
- tauri-prod-release
# This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-tauri:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm based macs (M1 and above).
args: '--target aarch64-apple-darwin'
# - platform: 'macos-latest' # for Intel based macs.
# args: '--target x86_64-apple-darwin'
# - platform: 'ubuntu-22.04' # for Tauri v1 you could replace this with ubuntu-20.04.
# args: ''
# - platform: 'windows-latest'
# args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
# You can remove the one that doesn't apply to your app to speed up the workflow a bit.
- name: install frontend dependencies
run: cd ./crates/desktop/artcraft && yarn install # change this to npm, pnpm or bun depending on which one you use.
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: ./crates/desktop/artcraft
tagName: app-v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
-4
View File
@@ -29,7 +29,6 @@ twitch_secrets.toml
production_database_secret_connect.sh
production_database_secret_query.sh
production_database_voice_clone_report_download.sh
script/artcraft/mac_signing_build.sh
script/production_database_secret_backup.sh
script/production_database_secret_connect.sh
script/socat_dev_database_connect.sh
@@ -56,9 +55,6 @@ crates/lib/sqlite_queries/target/
# Javascript build directories
node_modules/
# Frontend build outputs
frontend/apps/artcraft/bundle-report.html
# Vim noise
*.swo
*.swp
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET is_dismissed_by_user = 1\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "13d435d8f681b73e4080748dcef0579e246e41049af67e1bd79ffc06aa6ba974"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET\n task_status = ?,\n on_complete_batch_token = ?,\n on_complete_primary_media_file_token = ?,\n on_complete_primary_media_file_class = ?,\n on_complete_primary_media_file_cdn_url = ?,\n on_complete_primary_media_file_thumbnail_url_template = ?\n WHERE\n provider = ?\n AND provider_job_id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "1e3758851fc8451670f86a9432009b73bdb81de4a08dcaa96a86afb5f9be7a68"
}
@@ -1,128 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n on_complete_primary_media_file_token,\n on_complete_primary_media_file_class,\n on_complete_batch_token,\n on_complete_primary_media_file_cdn_url,\n on_complete_primary_media_file_thumbnail_url_template,\n on_failure_type,\n on_failure_message,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n completed_at as \"completed_at: DateTime<Utc>\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "task_status",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "task_type",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "model_type",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "provider",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "provider_job_id",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "frontend_caller",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "frontend_subscriber_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "frontend_subscriber_payload",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "on_complete_primary_media_file_token",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "on_complete_primary_media_file_class",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "on_complete_batch_token",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "on_complete_primary_media_file_cdn_url",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "on_complete_primary_media_file_thumbnail_url_template",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "on_failure_type",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "on_failure_message",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"ordinal": 16,
"type_info": "Int64"
},
{
"name": "updated_at: DateTime<Utc>",
"ordinal": 17,
"type_info": "Int64"
},
{
"name": "completed_at: DateTime<Utc>",
"ordinal": 18,
"type_info": "Int64"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
true
]
},
"hash": "2436eb7bf4b0e23a899311a67a77f71d89eb35461e2113e43cd9d221bfa91c9a"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET task_status = \"cancelled_by_user\"\n WHERE task_status NOT IN (\n \"complete_success\",\n \"complete_failure\",\n \"dead\",\n \"cancelled_by_user\",\n \"cancelled_by_provider\",\n \"cancelled_by_us\"\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "30272128d83543d9fbfca930b7cdd4af30adfb4d90f2de044463a111c6340495"
}
@@ -1,86 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n queue_status_url,\n queue_response_url,\n prompt_token,\n frontend_subscriber_id,\n frontend_subscriber_payload\n FROM tasks\n WHERE\n provider = ?\n AND provider_job_id = ?\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "task_status",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "task_type",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "model_type",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "provider",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "provider_job_id",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "frontend_caller",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "queue_status_url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "queue_response_url",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "prompt_token",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "frontend_subscriber_id",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "frontend_subscriber_payload",
"ordinal": 11,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "35677894f7b02f32a1bf6396fa6d10caba69bb0f3f03ee7ffbfa1437689d42df"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO tasks (\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n queue_status_url,\n queue_response_url,\n prompt_token,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "4fc9674ae39abaadc36054c200290ec95d0e2c7765336f0fa97805a2265f5c4c"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET\n task_status = ?,\n on_complete_batch_token = ?,\n on_complete_primary_media_file_token = ?,\n on_complete_primary_media_file_class = ?,\n on_complete_primary_media_file_cdn_url = ?,\n on_complete_primary_media_file_thumbnail_url_template = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "50a2449b87abed7df3302a74ecd0e8ab87940077a247636680cb09b2fe488321"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET task_status = ?,\n on_failure_type = ?,\n on_failure_message = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "9b99ccd0baf9a4b74d8903f63e3a3dcf4c09b75cad978f0532388cbd743b7516"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET task_status = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "dc0367e9e43c3d7dda3623e77337ab4529eac2192475be7d08589886703ba1cb"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE tasks\n SET is_dismissed_by_user = 1\n WHERE is_dismissed_by_user != 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "f83018ec5493982659a56576296914f937bb4e75db3a522eb4bda486d5caa402"
}
+29 -45
View File
@@ -4,54 +4,38 @@ This file provides guidance for coding agents when working with the Artcraft mon
## Project Overview
Artcraft is a web and desktop application for generating AI image and video. It is written in
Rust and TypeScript and contains desktop, server, and frontend components.
ArtCraft Services contains the Rust backend, HTTP API, workers, and TypeScript web
frontends for ArtCraft. The Tauri desktop application is maintained separately at
https://github.com/storytold/artcraft.
## Project Structure
```
artcraft/
├── _database/ # Schema definitions and migrations (MySQL, SQLite, Elasticsearch, etc.)
│ ├── elasticsearch/ # Elasticsearch schema and queries
│ └── sql/ # MySQL and SQLite schema definitions and migrations
│ ├── artcraft_migrations/ # ArtCraft desktop app SQLite migrations
│ ├── migrations/ # Server MySQL migrations
│ └── migrations_squashed/ # Fully materialized MySQL schema definitions for most tables
├── _tools/ # Various 3rd party tool integrations and configurations
│ └── postman/ # Postman configs for test HTTP requests against development and production
├── build/ # Dockerfile build instructions for server components
├── crates/ # Rust workspace
│ ├── api_clients/ # HTTP clients for calling internal and 3rd party services
│ ├── cli/ # Command line tools
│ ├── desktop/ # Desktop (Tauri) apps
│ │ └── artcraft/ # (Important) ArtCraft, the desktop app. This is one of our main pieces of software
│ ├── lib/ # Various utility libraries for servers, CLI tools, desktop, etc.
│ ├── schema/ # Data definition layer: MySQL, SQlite, Redis, S3/R2 buckets, etc.
│ │ ├── buckets/ # Declares R2 cloud bucket topology
│ │ ├── database/ # MySQL, SQLite, Elasticsearch, Redis, etc.
│ │ │ ├── elasticsearch_schema/ # Elasticsearch
│ │ │ ├── migration/ # (deprecated) Online schema adapters for MySQL
│ │ │ ├── mysql_queries/ # Sqlx MySQL queries for our backend monolith `storyteller-web`, jobs, etc.
│ │ │ ├── redis_common/ # Redis support
│ │ │ ├── redis_schema/ # Redis key and HKEY topology
│ │ │ ├── sqlite_queries/ # (deprecated) Sqlite queries
│ │ │ └── sqlite_tasks/ # Queries for the ArtCraft desktop app's "tasks" database.
│ │ ├── public/ # Token identifier and enum variant definitions
│ │ │ ├── composite_identifier/ # MySQL composite key system
│ │ │ ├── enums/ # MySQL "enums" stored in VARCHAR fields.
│ │ │ └── tokens/ # Primary database identifiers with Stripe-like ID prefixes, eg. "user_{entropy}"
│ │ └── service/ # Backend HTTP services and jobs
│ │ ├── job/ # Backend jobs.
│ │ │ └── video_thumbnail_job/ # Render video thumbnails
│ │ ├── plugins/ # Collections of reusable Actix-Web HTTP functions (user and billing systems)
│ │ └── web/ # HTTP web servers
│ │ └── storyteller_web/ # (Important) Our main HTTP API monolith and backend.
│ └── frontend/ # Nx typescript monorepo for our websites and Tauri desktop apps
│ ├── apps/ # Websites and Tauri desktop apps
│ │ ├── artcraft/ # ArtCraft the Tauri app's frontend. Used in conjunction with `artcraft` the Rust crate.
│ │ └── artcraft-website/ # The website for https://getartcraft.com
│ └── libs/ # Support libraries, reusable React components, etc.
└── Cargo.toml # Rust monorepo workspace
```text
artcraft-services/
├── _database/ # SQL migrations and Elasticsearch schemas
│ └── sql/
│ ├── migrations/ # Server MySQL migrations
│ └── migrations_squashed/ # Materialized MySQL table definitions
├── _tools/postman/ # HTTP request collections
├── build/ # Service Dockerfiles
├── crates/
│ ├── api_clients/ # ArtCraft API types, clients, router, provider clients
│ ├── cli/ # Development and operations tools
│ ├── lib/ # Shared Rust utilities
│ ├── schema/ # Database queries, public tokens/enums, bucket paths
│ │ └── database/
│ │ ├── mysql_queries/ # SQLx queries shared by services, jobs, and CLI tools
│ │ ├── redis_common/ # Redis support
│ │ └── redis_schema/ # Redis key and HKEY topology
│ └── service/
│ ├── job/ # Provider workers, media processing, analytics
│ ├── plugins/ # Shared billing and service components
│ └── web/storyteller_web/ # Main Actix Web HTTP API
├── frontend/
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
│ ├── apps/artcraft-website/ # Product website at getartcraft.com
│ └── libs/ # Shared React components and TypeScript libraries
└── Cargo.toml # Rust workspace
```
## Code Style
Generated
+65 -4118
View File
File diff suppressed because it is too large. Load diff
-32
View File
@@ -18,19 +18,15 @@ members = [
"crates/api_clients/google_drive_downloader",
"crates/api_clients/google_sign_in",
"crates/api_clients/grok_api_client",
"crates/api_clients/grok_consumer_client",
"crates/api_clients/gmicloud_client",
"crates/api_clients/midjourney_client",
"crates/api_clients/muapi_client",
"crates/api_clients/beeble_client",
"crates/api_clients/openai_sora_client",
"crates/api_clients/openart_client",
"crates/api_clients/rootly_client",
"crates/api_clients/runwayml_client",
"crates/api_clients/kinovi_web_client",
"crates/api_clients/thumbnail_generator",
"crates/api_clients/worldlabs_api_client",
"crates/api_clients/worldlabs_consumer_client",
# CLI tools for operations, etc.
"crates/cli/devenv/dev-database-seed",
@@ -44,27 +40,19 @@ members = [
"crates/cli/production/migration-tool",
"crates/cli/production/support-tool",
# Desktop apps
"crates/desktop/artcraft",
# Shared libraries
"crates/lib/actix_artcraft",
"crates/lib/actix_cors_configs",
"crates/lib/actix_helpers",
"crates/lib/bootstrap",
"crates/lib/build_metadata",
"crates/lib/caching/memory_caching",
"crates/lib/caching/memory_store",
"crates/lib/caching/redis_caching",
"crates/lib/cloudflare_errors",
"crates/lib/cloudflare_mitigation",
"crates/lib/concurrency",
"crates/lib/cookie_store",
"crates/lib/data/collections",
"crates/lib/data/crockford",
"crates/lib/data/datetimes",
"crates/lib/data/primitives",
"crates/lib/data/web_base64",
"crates/lib/easyenv", # NB: Vendored version of https://github.com/echelon/easyenv.rs that we'll improve.
"crates/lib/error_utils",
"crates/lib/errors",
@@ -123,7 +111,6 @@ members = [
"crates/schema/database/mysql_testing",
"crates/schema/database/redis_common",
"crates/schema/database/redis_schema",
"crates/schema/database/sqlite_tasks",
#"crates/schema/database/sqlite_queries",
# Schema (public): common strongly-typed tokens, ids, payloads, etc.
@@ -167,7 +154,6 @@ members = [
# Vendored code
"crates/vendor/limitation",
"crates/vendor/tauri-plugin-http",
"crates/vendor/Symphonia/symphonia",
"crates/vendor/Symphonia/symphonia-bundle-flac",
"crates/vendor/Symphonia/symphonia-bundle-mp3",
@@ -245,14 +231,11 @@ artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
bootstrap = { path = "crates/lib/bootstrap" }
bucket_client = { path = "crates/api_clients/bucket_client" }
bucket_paths = { path = "crates/schema/buckets/bucket_paths" }
build_metadata = { path = "crates/lib/build_metadata" }
cloud_storage = { path = "crates/api_clients/cloud_storage" }
cloudflare_errors = { path = "crates/lib/cloudflare_errors" }
cloudflare_mitigation = { path = "crates/lib/cloudflare_mitigation" }
collections = { path = "crates/lib/data/collections" }
component_traits = { path = "crates/service/plugins/component_traits" }
concurrency = { path = "crates/lib/concurrency" }
cookie_store = { path = "crates/lib/cookie_store" }
crockford = { path = "crates/lib/data/crockford" }
datadog_client = { path = "crates/api_clients/datadog_client" }
easyenv = { path = "crates/lib/easyenv" }
@@ -266,7 +249,6 @@ file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" }
filesys = { path = "crates/lib/files/filesys" }
beeble_client = { path = "crates/api_clients/beeble_client" }
grok_api_client = { path = "crates/api_clients/grok_api_client" }
grok_consumer_client = { path = "crates/api_clients/grok_consumer_client" }
rootly_client = { path = "crates/api_clients/rootly_client" }
rootly_config = { path = "crates/schema/config/rootly_config" }
shared_env_var_config = { path = "crates/schema/config/shared_env_var_config" }
@@ -277,21 +259,17 @@ jwt_light = { path = "crates/lib/jwt_light" }
jwt_signer = { path = "crates/lib/jwt_signer" }
opaque_cursors = { path = "crates/lib/opaque_cursors" }
memory_caching = { path = "crates/lib/caching/memory_caching" }
memory_store = { path = "crates/lib/caching/memory_store" }
midjourney_client = { path = "crates/api_clients/midjourney_client" }
mimetypes = { path = "crates/lib/files/mimetypes" }
muapi_client = { path = "crates/api_clients/muapi_client" }
metrics = { path = "crates/lib/metrics" }
mysql_queries = { path = "crates/schema/database/mysql_queries" }
mysql_testing = { path = "crates/schema/database/mysql_testing" }
pager = { path = "crates/lib/pager" }
openai_sora_client = { path = "crates/api_clients/openai_sora_client" }
primitives = { path = "crates/lib/data/primitives" }
kinovi_web_client = { path = "crates/api_clients/kinovi_web_client" }
server_environment = { path = "crates/lib/server_environment" }
sha1_hash = { path = "crates/lib/sha1_hash" }
shared_service_components = { path = "crates/lib/shared_service_components" }
sqlite_tasks = { path = "crates/schema/database/sqlite_tasks" }
subprocess_common = { path = "crates/lib/deprecated/subprocess_common" }
test_data = { path = "crates/testing/test_data" }
test_utils = { path = "crates/testing/test_utils" }
@@ -301,9 +279,7 @@ url_utils = { path = "crates/lib/url_utils" }
users = { path = "crates/lib/users" }
uuid_utils = { path = "crates/lib/uuid_utils" }
video_info = { path = "crates/lib/video_info" }
web_base64 = { path = "crates/lib/data/web_base64" }
worldlabs_api_client = { path = "crates/api_clients/worldlabs_api_client" }
worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_client" }
# External libs
# Centrally pinned versions so that we can uniformly upgrade/downgrade versions across the workspace
@@ -320,8 +296,6 @@ anyhow = "1.0.102"
assertor = "0.0.3"
base64 = "0.22.1"
bcrypt = "0.15"
bstr = "1.12.1"
byteorder = "1.5.0"
bytes = "1.11.1"
chrono = { version = "0.4.44", features = ["serde"] }
clap = { version = "=4.5.60", features = ["derive"] }
@@ -333,7 +307,6 @@ env_logger = "0.11.9"
futures = { version = "0.3.32" }
hex-literal = "1.1.0"
hostname = "0.4.0"
indexmap = "2.13.0"
log = "0.4.29"
md-5 = "0.10.6" # insecure but used for gravatar
mockall = "0.12.1"
@@ -355,11 +328,6 @@ sha2 = "0.11.0-rc.5"
sha3 = "0.10.8"
speculoos = "0.11.0"
strum = { version = "0.28.0", features = ["derive"] }
tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
tauri-plugin-log = "2.8.0"
tauri-plugin-upload = "2.4.0"
tempdir = "0.3.7"
tempfile = "3.27.0"
thiserror = "2.0.18"
+13 -16
View File
@@ -2,11 +2,11 @@
This repository contains ArtCraft's backend, HTTP API, background workers, and web
frontends. It is a Rust and TypeScript monorepo with shared API definitions,
provider clients, database queries, and development tooling. The checkout also
includes the Tauri desktop application and its shared frontend libraries.
provider clients, database queries, and development tooling.
For the product overview, feature demos, and desktop downloads, see
[storytold/artcraft](https://github.com/storytold/artcraft).
The Tauri desktop application is maintained in
[storytold/artcraft](https://github.com/storytold/artcraft), along with the product
overview, feature demos, and desktop downloads.
## Backend architecture
@@ -53,7 +53,6 @@ Storage responsibilities are split across these components:
| Redis | Caching, rate limiting, and job progress |
| Elasticsearch | Search indexes and queries |
| S3-compatible storage / R2 | Uploaded media, generated assets, and derived files |
| SQLite + SQLx | Local desktop task persistence |
HTTP routes and handlers live in
[`storyteller_web/src/http_server`](./crates/service/web/storyteller_web/src/http_server).
@@ -99,24 +98,23 @@ Start with these sources when adding or tracing an endpoint:
## Frontend
[`frontend`](./frontend) contains the Nx workspace for React and TypeScript apps
and shared libraries. The main web and desktop frontends use Vite, with Zustand
and shared libraries. The main web frontends use Vite, with Zustand
and signals for state, Three.js for 3D scenes, and shared UI and generation tools.
| Path | Purpose |
|----------------------------------|-----------------------------------------------|
| `frontend/apps/artcraft-webapp` | Browser application at `app.getartcraft.com` |
| `frontend/apps/artcraft-website` | Product website at `getartcraft.com` |
| `frontend/apps/artcraft` | Frontend for the Tauri desktop application |
| `frontend/libs/api` | HTTP clients, API host selection, and models |
| `frontend/libs/omni-gen` | Shared generation logic |
| `frontend/libs/components` | Reusable UI, editors, and generation controls |
| `frontend/libs/tauri-api` | Frontend bindings for native desktop commands |
| `frontend/libs/tauri-events` | Desktop event integration |
The web apps call the backend through the shared API library, which handles JSON
and multipart requests and session credentials. The desktop frontend also invokes
Rust commands and receives events through Tauri; the native application lives in
[`crates/desktop/artcraft`](./crates/desktop/artcraft).
and multipart requests and session credentials. Libraries such as `tauri-api` and
`tauri-utils` remain because shared web components still import their types,
helpers, and browser-compatible behavior. The native desktop app and libraries
used only by that app live in the separate desktop repository.
## Repository layout
@@ -129,10 +127,9 @@ artcraft-services/
│ ├── api_clients/ # ArtCraft API types, clients, router, provider clients
│ ├── schema/ # Database access, public tokens/enums, bucket paths
│ ├── lib/ # Shared Rust utilities
│ ├── cli/ # Development and operations tools
│ └── desktop/artcraft/ # Tauri desktop application
│ └── cli/ # Development and operations tools
├── frontend/
│ ├── apps/ # Web and desktop frontends
│ ├── apps/ # Web frontends
│ └── libs/ # Shared TypeScript libraries
├── _database/ # SQL migrations, materialized schemas, search schemas
├── _docs/ # Setup guides and technical documentation
@@ -201,8 +198,8 @@ npx nx dev artcraft-website
```
The website runs at `http://localhost:4200`. Repository-root launchers are in
[`script/website`](./script/website), and desktop launchers are in
[`script/artcraft`](./script/artcraft).
[`script/website`](./script/website). For desktop development, use the
[ArtCraft desktop repository](https://github.com/storytold/artcraft).
## Further reading
@@ -1,127 +0,0 @@
-- noinspection SqlDialectInspectionForFile
--
-- NB: This version needs to be bumped even if just adding or changing comments!
-- Failure to do so will cause Windows to segfault at start. (This is horrible!)
--
-- NB: `app_state_dir.rs` contains the file version number.
--
-- Migration history:
-- tasks_v1.sqlite - initial version
-- tasks_v2.sqlite - added model_type (nullable)
-- tasks_v3.sqlite - added frontend_caller, comments
-- tasks_v4.sqlite - added is_dismissed_by_user
-- tasks_v5.sqlite - added five fields:
-- on_complete_batch_token,
-- on_complete_primary_media_file_token,
-- on_complete_primary_media_file_class,
-- on_complete_primary_media_file_cdn_url,
-- on_complete_primary_media_file_thumbnail_url_template,
-- tasks_v6.sqlite - added two fields:
-- on_failure_type
-- on_failure_message
-- tasks_v7.sqlite - added prompt token + two fields (for fal):
-- prompt_token
-- queue_status_url
-- queue_response_url
CREATE TABLE tasks (
-- Task auto-incrementing primary key.
-- We don't use this.
id TEXT NOT NULL PRIMARY KEY,
-- TaskStatus enum
-- This is how the job system manages job states.
-- e.g. pending, started, complete_success, dead, etc.
task_status TEXT NOT NULL,
-- TaskType enum
-- e.g. image_generation, video_generation, etc.
task_type TEXT NOT NULL,
-- TaskModelType enum
-- e.g. flux_1_dev, veo_2, etc.
model_type TEXT,
-- GenerationProvider enum
-- Together with `provider_job_id`, this creates a foreign key lookup to the job.
-- e.g. 'artcraft', 'fal', 'sora', etc.
provider TEXT,
-- The primary key for the job in the provider's system.
provider_job_id TEXT,
-- OPTIONAL.
-- For third party jobs, we need to record the prompt details upfront.
-- We create a prompt record and store the token to associate once the task completes.
prompt_token TEXT,
-- OPTIONAL.
-- Tell the job system which caller initiated the task.
-- e.g. 'canvas'
frontend_caller TEXT,
-- OPTIONAL.
-- An arbitrary opaque identifier that frontend can set.
frontend_subscriber_id TEXT,
-- OPTIONAL.
-- An opaque JSON payload set by the frontend subscriber that
-- will be re-emitted back to the frontend.
frontend_subscriber_payload TEXT,
-- Whether the user has dismissed the task from view.
is_dismissed_by_user INTEGER NOT NULL DEFAULT 0,
-- OPTIONAL.
-- For some third party systems (eg. FAL), the URL to check the status of the
-- job in the queue.
queue_status_url TEXT,
-- OPTIONAL.
-- For some third party systems (eg. FAL), the URL to check the
-- results (when the job completes)
queue_response_url TEXT,
-- OPTIONAL.
-- When the generation completes, the batch token (if any).
-- This will point to a collection of multiple files.
on_complete_batch_token TEXT,
-- OPTIONAL.
-- When the generation completes, the media token of the "primary" media file.
-- We'll probably always generate homogenous types, but just in case this is the first or primary item in a batch.
on_complete_primary_media_file_token TEXT,
-- OPTIONAL.
-- When the generation completes, the file type of the primary media file.
-- We'll probably always generate homogenous types, but just in case this is the first or primary item in a batch.
on_complete_primary_media_file_class TEXT,
-- OPTIONAL.
-- The URL to the media file.
on_complete_primary_media_file_cdn_url TEXT,
-- OPTIONAL.
-- The URL template for the thumbnail of the primary media file.
on_complete_primary_media_file_thumbnail_url_template TEXT,
-- OPTIONAL.
-- An enum-like string describing the failure reason (even for non-terminal failures).
-- The enum is `TaskFailureType`, which is a close analogue of the backend API's `FrontendFailureCategory`.
on_failure_type TEXT,
-- OPTIONAL.
-- A human-readable message describing the failure reason (even for non-terminal failures).
on_failure_message TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
updated_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
completed_at INTEGER DEFAULT NULL
);
-- Unique Indices
CREATE UNIQUE INDEX idx_tasks_on_provider_job_id ON tasks(provider_job_id);
-- Indices
CREATE INDEX idx_tasks_on_status ON tasks(task_status);
CREATE INDEX idx_tasks_on_provider ON tasks(provider);
+27 -32
View File
@@ -1,45 +1,40 @@
Dev Setup
=========
# Development setup
# ArtCraft
This repository contains the ArtCraft backend and web frontends. Desktop development
lives in [storytold/artcraft](https://github.com/storytold/artcraft).
ArtCraft is a Rust / Tauri app.
## Backend
To set up the ArtCraft development environment, install the following:
Install Rust and Cargo, then follow the [server setup guide](./dev_setup_server.md)
for MySQL and Redis. The server also needs Elasticsearch configuration, object
storage, and credentials for the integrations you use. Configuration files live in
[`storyteller_web/config`](../crates/service/web/storyteller_web/config); dependency
initialization lives in [`startup`](../crates/service/web/storyteller_web/src/startup).
1. [Install Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html).
2. [Install npm](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm). (Node version `v24.13.0` works at time of writing.)
3. [Install nx](https://nx.dev/docs/getting-started/installation). (Nx version `v22.4.5` works at time of writing.)
4. [Install Tauri CLI](https://v2.tauri.app/reference/cli/). (Version `tauri-cli 2.10.0` works at time of writing.)
An easy way to get started with running the app in development is to run the two commands (in separate terminals):
**Mac and Linux Development**
From the repository root, with those dependencies configured:
```bash
# Run the frontend dev server
./script/artcraft/unix_frontend_dev.sh
# Run the Tauri Rust application
./script/artcraft/unix_rust_dev.sh
SQLX_OFFLINE=true cargo check -p storyteller-web
SQLX_OFFLINE=true cargo run -p storyteller-web
```
**Windows Development**
The API listens on `http://localhost:12345` by default. `SQLX_OFFLINE=true` uses
checked-in query metadata at build time; the server still needs MySQL at runtime.
See the [root README](../README.md) for configuration loading and worker setup.
```powershell
# Run the frontend dev server
.\script\artcraft\windows_frontend_dev.ps1
## Web frontends
# Run the Tauri Rust application
.\script\artcraft\windows_rust_dev.ps1
Install Node.js and npm, then run from the repository root:
```bash
cd frontend
npm install
VITE_USE_LOCAL_API=true npx nx dev artcraft-webapp
```
The web app runs at `http://localhost:4201`, using the local backend. To develop
against the hosted API, omit `VITE_USE_LOCAL_API=true`.
# ArtCraft Server
ArtCraft's server is a Rust / Actix app called `storyteller-web`.
You don't need to run this to develop the ArtCraft application, but it can be useful to spin up
a development instance for adding new server functions or as your own private local copy.
See [dev_setup_server.md](./dev_setup_server.md) for instructions.
For the product website, run `npx nx dev artcraft-website` from `frontend`; it uses
`http://localhost:4200`. Repository-root launchers live under [`script/website`](../script/website).
See the [frontend README](../frontend/README.md) for builds and troubleshooting.
+5 -7
View File
@@ -2,11 +2,9 @@
ArtCraft's server is a Rust / Actix app called `storyteller-web`.
You don't need to run this to develop the ArtCraft application, but it can be useful to spin up
a development instance for adding new server functions or as your own private local copy.
`storyteller-web` can function entirely within the development environment, and ArtCraft can be
pointed to a local instance of the server.
Run it locally to develop backend functionality or connect the web app to your
own API instance. The desktop app is maintained in
[storytold/artcraft](https://github.com/storytold/artcraft).
**TODO**: Concisely describe setting up server components. Note: we have
previous docs in `old/` that may still be relevant, though they're perhaps
@@ -53,7 +51,7 @@ Then verify access with `./script/dev_mysql_connect.sh`
```bash
cargo install diesel_cli \
--no-default-features \
--features mysql,sqlite
--features mysql
```
If this fails,
@@ -90,7 +88,7 @@ Finally, you'll need the sqlx CLI tool to run codegen.
You likely won't need this now, but if you change any queries, this will be necessary:
```bash
cargo install sqlx-cli --features rustls,mysql,sqlite
cargo install sqlx-cli --version 0.7.4 --no-default-features --features rustls,mysql --locked
```
### Install Redis (Mac)
-1
View File
@@ -50,7 +50,6 @@ helpers go in the parent `mod tests` so sub-modules can `use super::*`.
## Key Crates
- `storyteller_web` — main HTTP API monolith (actix-web)
- `artcraft` (desktop) — Tauri desktop app
- `artcraft_router` — provider routing for image/video generation (Artcraft, Fal, KinoviWeb, Muapi)
- `artcraft_api_defs` — shared HTTP API type definitions
- `kinovi_web_client` — HTTP client for the Kinovi/KinoviWeb video generation service
@@ -1,52 +0,0 @@
[package]
name = "grok_consumer_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "grok_consumer_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
cloudflare_errors.workspace = true
cloudflare_mitigation.workspace = true
cookie_store.workspace = true
errors.workspace = true
# External
anyhow.workspace = true
base64.workspace = true
bstr.workspace = true
byteorder.workspace = true
chrono.workspace = true
futures.workspace = true
log.workspace = true
once_cell.workspace = true
regex.workspace = true
scraper = "0.24.0"
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tokio = { version = "1", features = ["full"] }
url.workspace = true
uuid.workspace = true
wreq-util.workspace = true
wreq.workspace = true
[dev-dependencies]
filesys.workspace = true
test_data.workspace = true
test_utils.workspace = true
log4rs = "1.4.0"
env_logger.workspace = true
env.workspace = true
chrono.workspace = true
@@ -1,10 +0,0 @@
/// Firefox 139 (2025) on macOS, which matches wreq's Emulation::Firefox139
pub const FIREFOX_139_MAC_USER_AGENT : &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0";
pub const FIREFOX_143_MAC_USER_AGENT : &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0";
pub const FIREFOX_144_MAC_USER_AGENT_UNSUPPORTED_TLS_FINGERPRINTING : &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0";
@@ -1 +0,0 @@
pub mod browser_user_agents;
@@ -1,517 +0,0 @@
#!/usr/bin/env python3
# From https://github.com/kbtit25/grok2api/blob/73c917b15438e12699531ccf9411d3a7d1110cbf/xStatsigIDGenerator.py
# -*- coding: utf-8 -*-
import base64
import struct
import hashlib
import time
import secrets
import requests
import re
import json
from typing import Optional, Dict, Any
class XStatsigIDGenerator:
"""x-statsig-id 生成器"""
def __init__(self):
self.base_timestamp = int(time.time()) # 使用当前系统时间
self.grok_url = "https://grok.com"
def get_grok_meta_content(self) -> bytes:
"""
从 grok.com 获取 meta 标签中的 grok-site-verification 内容
使用多种方法彻底解决403问题
Returns:
48字节的meta内容
"""
print("🌐 正在请求 grok.com...")
# 定义多种绕过策略
strategies = [
#self._try_curl_with_proxy,
self._try_curl_with_different_ua,
#self._try_requests_with_session,
#self._try_curl_cffi_advanced,
#self._try_alternative_endpoints,
#self._try_cached_content
]
for i, strategy in enumerate(strategies):
try:
print(f" 尝试策略 {i+1}: {strategy.__name__}")
result = strategy()
if result:
return result
except Exception as e:
print(f" 策略 {i+1} 失败: {e}")
continue
# 所有策略都失败,使用备用内容
print(" ❌ 所有策略都失败,使用备用meta内容")
fallback = b"backup-grok-meta-content-when-request-fails-ok"
return fallback + b'\x00' * (48 - len(fallback))
def _try_curl_with_proxy(self) -> bytes:
"""策略1: 使用curl + 代理"""
import subprocess
# 尝试多个公共代理
proxies = [
None, # 无代理
"socks5://127.0.0.1:1080", # 本地代理
"http://127.0.0.1:8080", # 本地HTTP代理
]
for proxy in proxies:
try:
curl_command = [
'curl', '-s', '-L', '--max-time', '15',
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'--header', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'--header', 'Accept-Language: en-US,en;q=0.5',
'--header', 'Accept-Encoding: gzip, deflate',
'--header', 'Connection: keep-alive',
'--header', 'Upgrade-Insecure-Requests: 1',
'--compressed'
]
if proxy:
curl_command.extend(['--proxy', proxy])
curl_command.append(self.grok_url)
result = subprocess.run(curl_command, capture_output=True, text=True, timeout=15)
if result.returncode == 0 and len(result.stdout) > 1000:
print(f" ✅ curl成功 (代理: {proxy or '无'})")
return self._extract_meta_from_html(result.stdout)
except Exception:
continue
return None
def _try_curl_with_different_ua(self) -> bytes:
"""策略2: 使用不同的User-Agent"""
import subprocess
user_agents = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0',
#'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
#'Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/109.0 Firefox/119.0',
#'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
#'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15'
]
for ua in user_agents:
try:
curl_command = [
'curl-impersonate-ff', '-s', '-L', '--max-time', '10',
'--user-agent', ua,
'--header', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'--header', 'Accept-Language: en-US,en;q=0.9',
'--header', 'Cache-Control: no-cache',
'--header', 'Pragma: no-cache',
'--compressed',
self.grok_url
]
result = subprocess.run(curl_command, capture_output=True, text=True, timeout=10)
if result.returncode == 0 and len(result.stdout) > 1000:
print(f" ✅ 不同UA成功")
return self._extract_meta_from_html(result.stdout)
except Exception:
continue
return None
def _try_requests_with_session(self) -> bytes:
"""策略3: 使用requests session模拟真实浏览器行为"""
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
# 配置重试策略
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
# 模拟真实浏览器行为
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Cache-Control': 'max-age=0'
}
try:
# 先访问主页建立session
session.get('https://x.com', headers=headers, timeout=5)
# 再访问grok
response = session.get(self.grok_url, headers=headers, timeout=10)
if response.status_code == 200 and len(response.text) > 1000:
print(f" ✅ session请求成功")
return self._extract_meta_from_html(response.text)
except Exception:
pass
return None
def _try_curl_cffi_advanced(self) -> bytes:
"""策略4: 使用curl_cffi高级模拟"""
try:
from curl_cffi import requests as curl_requests
# 尝试不同的浏览器模拟
impersonations = ['chrome120', 'chrome119', 'safari17', 'firefox119']
for imp in impersonations:
try:
response = curl_requests.get(
self.grok_url,
impersonate=imp,
timeout=10,
headers={
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache'
}
)
if response.status_code == 200 and len(response.text) > 1000:
print(f" ✅ curl_cffi成功 ({imp})")
return self._extract_meta_from_html(response.text)
except Exception:
continue
except ImportError:
pass
return None
def _try_alternative_endpoints(self) -> bytes:
"""策略5: 尝试替代端点"""
import subprocess
# 尝试不同的URL路径
alternative_urls = [
'https://grok.com/',
'https://grok.com/login',
'https://grok.com/home',
'https://x.com/i/grok'
]
for url in alternative_urls:
try:
curl_command = [
'curl', '-s', '-L', '--max-time', '8',
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'--header', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'--compressed',
url
]
result = subprocess.run(curl_command, capture_output=True, text=True, timeout=8)
if result.returncode == 0 and len(result.stdout) > 500:
print(f" ✅ 替代端点成功: {url}")
meta_result = self._extract_meta_from_html(result.stdout)
if meta_result:
return meta_result
except Exception:
continue
return None
def _try_cached_content(self) -> bytes:
"""策略6: 使用缓存或预设内容"""
# 如果有真实的grok meta内容,可以在这里硬编码
known_meta_contents = [
"grok-site-verification-content-2024-production-v1",
"x-grok-verification-meta-content-stable-release",
"grok-meta-verification-string-for-api-access"
]
for content in known_meta_contents:
try:
meta_bytes = content.encode('utf-8')
if len(meta_bytes) < 48:
meta_bytes = meta_bytes + b'\x00' * (48 - len(meta_bytes))
elif len(meta_bytes) > 48:
meta_bytes = meta_bytes[:48]
print(f" ✅ 使用预设内容: {content[:30]}...")
return meta_bytes
except Exception:
continue
return None
def _extract_meta_from_html(self, html_content: str) -> bytes:
"""从HTML中提取meta内容"""
patterns = [
r'<meta\s+name=["\']grok-site-verification["\']\s+content=["\']([^"\']+)["\']',
r'<meta\s+content=["\']([^"\']+)["\']\s+name=["\']grok-site-verification["\']',
r'grok-site-verification["\']?\s*(?:content|value)\s*=\s*["\']([^"\']+)["\']',
# 扩展模式,查找其他可能的meta标签
r'<meta\s+name=["\']verification["\']\s+content=["\']([^"\']+)["\']',
r'<meta\s+name=["\']site-verification["\']\s+content=["\']([^"\']+)["\']'
]
for pattern in patterns:
match = re.search(pattern, html_content, re.IGNORECASE)
if match:
verification_content = match.group(1)
print(f" ✅ 找到meta内容: {verification_content[:50]}...")
# 转换为48字节
meta_bytes = verification_content.encode('utf-8')
if len(meta_bytes) < 48:
meta_bytes = meta_bytes + b'\x00' * (48 - len(meta_bytes))
elif len(meta_bytes) > 48:
meta_bytes = meta_bytes[:48]
return meta_bytes
# 如果没找到特定的meta标签,尝试从HTML中提取其他有用信息
if 'grok' in html_content.lower() and len(html_content) > 1000:
# 使用HTML内容的哈希作为meta内容
import hashlib
content_hash = hashlib.sha256(html_content.encode()).hexdigest()[:48]
meta_bytes = content_hash.encode('utf-8')
if len(meta_bytes) < 48:
meta_bytes = meta_bytes + b'\x00' * (48 - len(meta_bytes))
print(f" ✅ 使用内容哈希作为meta: {content_hash[:32]}...")
return meta_bytes
return None
def generate_browser_fingerprint(self) -> str:
"""
生成浏览器指纹信息 (结合方法1和方法3)
Returns:
指纹字符串
"""
print("🔍 生成浏览器指纹...")
# 模拟浏览器指纹信息
fingerprint_data = {
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0",
"language": "en",
"languages": ["en", "en-US"],
"platform": "MacIntel",
"cookieEnabled": True,
"doNotTrack": None,
"screenWidth": 450,
"screenHeight": 654,
"screenColorDepth": 24,
"screenPixelDepth": 24,
"screenAvailWidth": 450,
"screenAvailHeight": 654,
"innerWidth": 450,
"innerHeight": 654,
"outerWidth": 1920,
"outerHeight": 1055,
"timezone": "America/New York",
"timezoneOffset": -400,
"hardwareConcurrency": 14,
"deviceMemory": 16,
"maxTouchPoints": 0
}
# 方法3: 生成指纹哈希
fingerprint_string = json.dumps(fingerprint_data, sort_keys=True, separators=(',', ':'))
fingerprint_hash = hashlib.sha256(fingerprint_string.encode('utf-8')).hexdigest()
print(f" 指纹数据长度: {len(fingerprint_string)} 字符")
print(f" 指纹哈希: {fingerprint_hash[:32]}...")
return fingerprint_hash
def generate_x_statsig_id(self, method: str = "GET", pathname: str = "/") -> str:
"""
生成完整的 x-statsig-id
Args:
method: 请求方式 (GET/POST)
pathname: 请求路径
Returns:
生成的 x-statsig-id 字符串
"""
print("=" * 60)
print("🚀 开始生成 x-statsig-id")
print("=" * 60)
print(f"📋 生成参数:")
print(f" Method: {method}")
print(f" Pathname: {pathname}")
# 1. 获取 grok.com 的 meta content
meta_content = self.get_grok_meta_content()
# 2. 生成浏览器指纹
fingerprint = self.generate_browser_fingerprint()
# 3. 生成当前时间戳
current_timestamp = int(time.time())
relative_timestamp = current_timestamp - self.base_timestamp
print(f"⏰ 时间信息:")
print(f" 当前时间戳: {current_timestamp}")
print(f" 基准时间戳: {self.base_timestamp}")
print(f" 相对时间戳: {relative_timestamp}")
# 4. 生成时间戳字节 (小端序)
timestamp_bytes = struct.pack('<I', relative_timestamp)
print(f" 时间戳字节: {timestamp_bytes.hex()}")
# 5. 生成SHA256
sha_input = f"{method}!{pathname}!{relative_timestamp}{fingerprint}"
sha256_hash = hashlib.sha256(sha_input.encode('utf-8')).digest()
sha256_16bytes = sha256_hash[:16]
print(f"🔐 SHA256信息:")
print(f" 输入字符串: {sha_input[:100]}...")
print(f" SHA256前16字节: {sha256_16bytes.hex()}")
# 6. 固定值
fixed_byte = b'\x03'
# 7. 组合payload数据
payload_data = meta_content + timestamp_bytes + sha256_16bytes + fixed_byte
print(f"📦 Payload长度: {len(payload_data)} 字节")
# 8. 生成异或key并加密
xor_key = secrets.randbits(8)
encrypted_payload = bytes([b ^ xor_key for b in payload_data])
print(f"🔑 异或信息:")
print(f" 异或key: 0x{xor_key:02x} ({xor_key})")
# 9. 组合最终数据
final_data = bytes([xor_key]) + encrypted_payload
print(f" 最终数据长度: {len(final_data)} 字节")
# 10. Base64编码
result = base64.b64encode(final_data).decode('utf-8')
print(f"✅ 生成结果:")
print(f" x-statsig-id: {result}")
print(f" 长度: {len(result)} 字符")
return result
def verify_generated_id(self, statsig_id: str) -> bool:
"""
验证生成的ID结构是否正确
Args:
statsig_id: 要验证的ID
Returns:
验证是否通过
"""
print("\n" + "=" * 60)
print("🔍 验证生成的ID结构")
print("=" * 60)
try:
# Base64解码
decoded_bytes = base64.b64decode(statsig_id)
print(f"✅ Base64解码成功,长度: {len(decoded_bytes)} 字节")
# 提取异或key
xor_key = decoded_bytes[0]
print(f"✅ 异或key: 0x{xor_key:02x} ({xor_key})")
# 异或解密
decrypted = bytearray()
for i in range(1, len(decoded_bytes)):
decrypted.append(decoded_bytes[i] ^ xor_key)
print(f"✅ 解密后长度: {len(decrypted)} 字节")
# 验证数据结构
expected_length = 48 + 4 + 16 + 1 # meta + timestamp + sha256 + fixed
if len(decrypted) == expected_length:
print(f"✅ 数据长度正确: {len(decrypted)}/{expected_length}")
else:
print(f"❌ 数据长度错误: {len(decrypted)}/{expected_length}")
return False
# 检查固定值
fixed_val = decrypted[-1]
if fixed_val == 3:
print(f"✅ 固定值正确: {fixed_val}")
else:
print(f"❌ 固定值错误: {fixed_val} (期望: 3)")
return False
# 解析时间戳
timestamp_bytes = decrypted[48:52]
timestamp = struct.unpack('<I', timestamp_bytes)[0]
actual_time = self.base_timestamp + timestamp
print(f"✅ 时间戳解析:")
print(f" 相对时间: {timestamp} 秒")
print(f" 绝对时间: {actual_time}")
print(f" 时间差: {abs(time.time() - actual_time):.1f} 秒")
print("🎉 ID结构验证通过!")
return True
except Exception as e:
print(f"❌ 验证失败: {e}")
return False
def main():
"""主函数 - 演示完整流程"""
generator = XStatsigIDGenerator()
# 生成 x-statsig-id
method = "POST"
pathname = "/rest/app-chat/conversations/new"
statsig_id = generator.generate_x_statsig_id(method, pathname)
print("Generated id", statsig_id)
# 验证生成的ID
generator.verify_generated_id(statsig_id)
print(f"\n" + "=" * 60)
print("📋 使用说明")
print("=" * 60)
print("在HTTP请求中使用:")
print(f"Headers: {{'x-statsig-id': '{statsig_id}'}}")
if __name__ == "__main__":
main()
@@ -1,193 +0,0 @@
#!/usr/bin/env python3
# From https://github.com/realasfngl/Grok-Api/blob/d8f7c6754d95ad3707b214f93930ee454724035d/core/xctid.py
from math import floor, copysign, pi, cos, sin
from base64 import b64decode, b64encode
from re import findall, sub
from typing import List, Dict
from random import random
from hashlib import sha256
from struct import pack
from time import time
class Signature:
@staticmethod
def _h(x: float, _param: float, c: float, e: bool):
f = ((x * (c - _param)) / 255.0) + _param
if e:
return floor(f)
rounded = round(float(f), 2)
if rounded == 0.0:
return 0.0
return rounded
@staticmethod
def cubicBezierEased(t: float, x1: float, y1: float, x2: float, y2: float) -> float:
def bezier(u: float):
omu = 1.0 - u
b1 = 3.0 * omu * omu * u
b2 = 3.0 * omu * u * u
b3 = u * u * u
x = b1 * x1 + b2 * x2 + b3
y = b1 * y1 + b2 * y2 + b3
return x, y
lo, hi = 0.0, 1.0
for _ in range(80):
mid = 0.5 * (lo + hi)
if bezier(mid)[0] < t:
lo = mid
else:
hi = mid
u = 0.5 * (lo + hi)
return bezier(u)[1]
@staticmethod
def xa(svg: str) -> List[List[int]]:
s = (svg)
substr = s[9:]
parts = substr.split("C")
out = []
for part in parts:
cleaned = sub(r"[^\d]+", " ", part).strip()
if cleaned == "":
nums = [0]
else:
nums = [int(tok) for tok in cleaned.split() if tok != ""]
out.append(nums)
return out
@staticmethod
def tohex(num: float) -> str:
rounded = round(float(num), 2)
if rounded == 0.0:
return "0"
sign = "-" if copysign(1.0, rounded) < 0 else ""
absval = abs(rounded)
intpart = int(floor(absval))
frac = absval - intpart
if frac == 0.0:
return sign + format(intpart, "x")
frac_digits = []
f = frac
for _ in range(20):
f *= 16
digit = int(floor(f + 1e-12))
frac_digits.append(format(digit, "x"))
f -= digit
if abs(f) < 1e-12:
break
frac_str = "".join(frac_digits).rstrip("0")
if frac_str == "":
return sign + format(intpart, "x")
return sign + format(intpart, "x") + "." + frac_str
@staticmethod
def simulateStyle(values: List[int], c: int) -> Dict[str,str]:
duration = 4096
currentTime = round(c / 10.0) * 10
t = currentTime / duration
cp = [Signature._h(v, -1 if (i % 2) else 0, 1, False) for i, v in enumerate(values[7:])]
easedY = Signature.cubicBezierEased(t, cp[0], cp[1], cp[2], cp[3])
start = [float(x) for x in values[0:3]]
end = [float(x) for x in values[3:6]]
r = round(start[0] + (end[0] - start[0]) * easedY)
g = round(start[1] + (end[1] - start[1]) * easedY)
b = round(start[2] + (end[2] - start[2]) * easedY)
color = f"rgb({r}, {g}, {b})"
endAngle = Signature._h(values[6], 60, 360, True)
angle = endAngle * easedY
rad = angle * pi / 180.0
def is_effectively_zero(val: float) -> bool:
return abs(val) < 1e-7
def is_effectively_integer(val: float) -> bool:
return abs(val - round(val)) < 1e-7
cosv = cos(rad)
sinv = sin(rad)
if is_effectively_zero(cosv):
a = 0
d = 0
else:
if is_effectively_integer(cosv):
a = int(round(cosv))
d = int(round(cosv))
else:
a = f"{cosv:.6f}"
d = f"{cosv:.6f}"
if is_effectively_zero(sinv):
bval = 0
cval = 0
else:
if is_effectively_integer(sinv):
bval = int(round(sinv))
cval = int(round(-sinv))
else:
bval = f"{sinv:.7f}"
cval = f"{(-sinv):.7f}"
transform = f"matrix({a}, {bval}, {cval}, {d}, 0, 0)"
return {"color": color, "transform": transform}
@staticmethod
def xs(x_bytes: bytes, svg: str, x_values: list) -> str:
arr = list(x_bytes)
idx = arr[x_values[0]] % 16
c = ((arr[x_values[1]] % 16) * (arr[x_values[2]] % 16)) * (arr[x_values[3]] % 16)
o = Signature.xa(svg)
vals = o[idx]
k = Signature.simulateStyle(vals, c)
concat = str(k["color"]) + str(k["transform"])
matches = findall(r"[\d\.\-]+", concat)
converted = []
for m in matches:
num = float(m)
hexstr = Signature.tohex(num)
converted.append(hexstr)
joined = "".join(converted)
cleaned = joined.replace(".", "").replace("-", "")
return cleaned
@staticmethod
def generate_sign(path: str, method: str, verification: str, svg: str, x_values: list, time_n: int = None, random_float: float = None) -> str:
n = int(time() - 1682924400) if not time_n else time_n
t = pack('<I', n)
r = b64decode(verification)
o = Signature.xs(r, svg, x_values)
msg = "!".join([method, path, str(n)]) + "obfiowerehiring" + o
digest = sha256(msg.encode('utf-8')).digest()[:16]
prefix_byte = int(floor(random() if not random_float else random_float * 256))
assembled = bytes([prefix_byte]) + r + t + digest + bytes([3])
arr = bytearray(assembled)
if len(arr) > 0:
first = arr[0]
for i in range(1, len(arr)):
arr[i] = arr[i] ^ first
return b64encode(bytes(arr)).decode('ascii').replace('=', '')
def main():
sig = Signature.generate_sign('/rest/app-chat/conversations/new', 'POST', self.verification_token, self.svg_data, self.numbers)
print('Signature', sig)
pass
if __name__ == 'main':
main()
@@ -1,36 +0,0 @@
use crate::datatypes::api::baggage::Baggage;
use crate::datatypes::api::sentry_trace::SentryTrace;
use crate::datatypes::api::svg_path_data::SvgPathData;
use crate::datatypes::api::user_email::UserEmail;
use crate::datatypes::api::user_id::UserId;
use crate::datatypes::api::verification_token::VerificationToken;
use crate::datatypes::api::xsid_numbers::XsidNumbers;
#[derive(Clone)]
pub struct GrokClientSecrets {
/// Baggage is <meta> tag data from the index.html
pub baggage: Baggage,
/// Baggage is <meta> tag data from the index.html
pub sentry_trace: SentryTrace,
/// Baggage is <meta> tag data from the index.html
pub verification_token: VerificationToken,
/// The SVG path data to use (chosen via verification_token)
/// We selected one of four-ish possible SVG paths (of length >= 200) by
/// the `verification token -> animation index` algo.
pub svg_path_data: SvgPathData,
/// This doesn't come from the index.html
/// These numbers are separately loaded from the xsid javascript
pub numbers: XsidNumbers,
/// From index.html
/// Not strictly needed to sign requests, but typically needed to generate URLs.
pub user_id: UserId,
/// From index.html
/// Not needed, but returned alongside other details.
pub user_email: Option<UserEmail>,
}
@@ -1,24 +0,0 @@
#[derive(Clone)]
pub struct GrokCookies {
/// The entire cookies header (for now)
cookies: String,
}
impl GrokCookies {
pub fn new(cookies: String) -> Self {
Self { cookies }
}
pub fn as_str(&self) -> &str {
&self.cookies
}
pub fn as_bytes(&self) -> &[u8] {
self.cookies.as_bytes()
}
pub fn to_string(&self) -> String {
self.cookies.clone()
}
}
@@ -1,30 +0,0 @@
use crate::credentials::grok_client_secrets::GrokClientSecrets;
use crate::credentials::grok_cookies::GrokCookies;
use crate::datatypes::api::user_id::UserId;
/// Includes the cookies and all the magic other bits needed to call the API.
#[derive(Clone)]
pub struct GrokFullCredentials {
/// Entire cookie payload
pub cookies: GrokCookies,
/// Secrets from `index.html` and the xsid javascript.
/// Required for video generation.
pub client_secrets: GrokClientSecrets,
}
impl GrokFullCredentials {
pub fn from_cookies_and_client_secrets(
cookies: GrokCookies,
client_secrets: GrokClientSecrets
) -> Self {
Self {
cookies,
client_secrets,
}
}
pub fn get_user_id_ref(&self) -> &UserId {
&self.client_secrets.user_id
}
}
@@ -1,22 +0,0 @@
use crate::datatypes::api::user_email::UserEmail;
use crate::datatypes::api::user_id::UserId;
#[derive(Clone)]
pub struct GrokUserData {
/// From index.html
/// Not strictly needed to sign requests, but typically needed to generate URLs.
pub user_id: UserId,
/// From index.html
/// Not needed, but returned alongside other details.
pub user_email: Option<UserEmail>,
}
impl GrokUserData {
pub fn from_id_and_email(id: String, email: Option<String>) -> Self {
Self {
user_id: UserId(id),
user_email: email.map(|email| UserEmail(email)),
}
}
}
@@ -1,4 +0,0 @@
pub mod grok_client_secrets;
pub mod grok_cookies;
pub mod grok_full_credentials;
pub mod grok_user_data;
@@ -1,13 +0,0 @@
use serde::Serialize;
#[derive(Serialize, Clone, Copy, Debug)]
pub enum AspectRatio {
#[serde(rename = "2:3")]
TallTwoByThree,
#[serde(rename = "3:2")]
WideThreeByTwo,
#[serde(rename = "1:1")]
Square,
}
@@ -1,4 +0,0 @@
/// "baggage" data from the <meta> tags
#[derive(Debug, Clone)]
pub struct Baggage(pub(crate) String);
@@ -1,5 +0,0 @@
/// Type for File IDs.
/// File IDs are UUIds.
#[derive(Clone, Debug)]
pub struct FileId(pub String);
@@ -1,12 +0,0 @@
pub mod aspect_ratio;
pub mod baggage;
pub mod file_id;
pub mod video_generation_mode;
pub mod post_id;
pub mod request_id;
pub mod sentry_trace;
pub mod svg_path_data;
pub mod user_email;
pub mod user_id;
pub mod verification_token;
pub mod xsid_numbers;
@@ -1,12 +0,0 @@
/// Type for Post IDs.
/// Post IDs are UUIds.
/// These are often shared with media/image uploads' `FileId`s, but seldom video generation `FileId`s
#[derive(Clone, Debug)]
pub struct PostId(pub String);
impl PostId {
pub fn to_string(&self) -> String {
self.0.clone()
}
}
@@ -1,13 +0,0 @@
/// Request IDs.
/// Type for image websocket "tasks"
/// These are for image generation websockets.
/// Request IDs are UUIds.
#[derive(Clone, Debug)]
pub struct RequestId(pub String);
impl RequestId {
pub fn to_string(&self) -> String {
self.0.clone()
}
}
@@ -1,40 +0,0 @@
use uuid::Uuid;
/// "sentry trace" data from the <meta> tags
#[derive(Debug, Clone)]
pub struct SentryTrace(pub (crate) String);
impl SentryTrace {
/// Generate the format of trace sent in the HTTP request headers
pub fn to_http_request_header(&self) -> String {
// Python reference:
// f'{self.sentry_trace}-{str(uuid4()).replace("-", "")[:16]}-0',
// Inner bit:
// str(uuid4()).replace("-", "")[:16]
let sentry_start = &self.0;
let sentry_uuid = Uuid::new_v4().to_string();
let sentry_inner = sentry_uuid.replace("-", "")[..16].to_string();
format!("{sentry_start}-{sentry_inner}-0")
}
}
#[cfg(test)]
mod tests {
use crate::datatypes::api::sentry_trace::SentryTrace;
#[test]
fn test_real_trace() {
let short = SentryTrace("d6d7c55e4a489c0dabaed06e5c7b257b".to_string());
let header = short.to_http_request_header();
assert_eq!(header.len(), 51);
}
#[test]
fn test_invalid_trace() {
let short = SentryTrace("".to_string());
let header = short.to_http_request_header();
assert_eq!(header.len(), 19); // header is invalid, we're just making sure Rust doesn't panic
}
}
@@ -1,4 +0,0 @@
/// Data on an SVG path, eg. <svg d={data} />
#[derive(Debug, Clone)]
pub struct SvgPathData(pub(crate) String);
@@ -1,9 +0,0 @@
/// Type for User Emails
#[derive(Clone, Debug)]
pub struct UserEmail(pub String);
impl UserEmail {
pub fn to_string(&self) -> String {
self.0.clone()
}
}
@@ -1,12 +0,0 @@
/// Type for User IDs.
/// User IDs are UUIDs.
#[derive(Clone, Debug)]
pub struct UserId(pub String);
impl UserId {
pub fn to_string(&self) -> String {
self.0.clone()
}
}
@@ -1,3 +0,0 @@
#[derive(Debug, Clone)]
pub struct VerificationToken(pub(crate) String);
@@ -1,27 +0,0 @@
/// The video generation mode selector.
#[derive(Copy, Clone, Debug)]
pub enum VideoGenerationMode {
/// Supposedly the "NSFW" mode.
Spicy,
Fun,
Normal,
/// This might be the only mode that allows for a custom text prompt
Custom,
}
impl VideoGenerationMode {
/// This is the value for `--mode` in video generation chats.
pub fn as_api_mode_arg(&self) -> &'static str {
match self {
Self::Spicy => "extremely-spicy-or-crazy",
Self::Fun => "extremely-crazy",
Self::Normal => "normal",
Self::Custom => "custom",
}
}
}
@@ -1,6 +0,0 @@
/// Magic numbers pulled from Javascript
#[derive(Clone, Debug)]
pub struct XsidNumbers {
pub numbers: Vec<u32>,
}
@@ -1,18 +0,0 @@
use std::path::Path;
/// Descriptor of a file to be uploaded
/// Includes necessary metadata
pub enum FileUploadSpec<P: AsRef<Path>> {
/// Path to the file
Path(P),
/// Bytes of the file
Bytes {
/// Must be owned.
bytes: Vec<u8>,
/// Must be owned.
filename: String,
/// Mime type
mimetype: String,
}
}
@@ -1,2 +0,0 @@
pub mod api;
pub mod file_upload_spec;
@@ -1,37 +0,0 @@
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::error::grok_specific_api_error::GrokSpecificApiError;
use cloudflare_errors::filter_cloudflare_errors::filter_cloudflare_errors;
use wreq::StatusCode;
/// We know the response is an error at this point.
/// We're just turning it into the right error.
pub fn categorize_grok_http_error(status_code: StatusCode, maybe_body: Option<&str>) -> GrokError {
if let Some(body) = maybe_body {
if let Err(err) = filter_cloudflare_errors(status_code.as_u16(), body) {
return GrokGenericApiError::CloudflareError(err).into();
}
let body = body.to_lowercase();
let anti_bot = body.contains("anti-bot") || body.contains("rejected");
if anti_bot {
return GrokSpecificApiError::AutomationBlocked.into();
}
match status_code {
StatusCode::TOO_MANY_REQUESTS => {
return GrokSpecificApiError::TooManyVideos.into();
}
_ => {},
}
return GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body: body.to_string(),
}.into();
}
GrokGenericApiError::UncategorizedBadResponseWithStatus(status_code).into()
}
@@ -1,154 +0,0 @@
use base64::DecodeError;
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum GrokClientError {
/// An error was encountered in building the Wreq client
WreqClientError(wreq::Error),
/// Error serializing a message to send the websocket
WebsocketRequestSerializationError(serde_json::Error),
/// Error locking the websocket for sending/receiving
WebsocketLockError,
/// Error reading from a websocket.
WebsocketReadError(wreq::Error),
/// Error sending to an open websocket.
WebsocketSendError(wreq::Error),
/// Can't open a local file for uploading.
CannotOpenLocalFileForUpload(std::io::Error),
/// Can't read a local file for uploading.
CannotReadLocalFileForUpload(std::io::Error),
/// Couldn't write to the file
CannotOpenLocalFileForWriting(std::io::Error),
/// Couldn't write to the file
CannotWriteLocalFile(std::io::Error),
/// File for upload has an invalid path.
FileForUploadHasInvalidPath,
/// Error parsing HTML
HtmlParsingError,
/// Error decoding verification token
FailedToDecodeVerificationToken(DecodeError),
/// Verification token bytes are invalid
InvalidVerificationTokenBytes,
/// Our script logic is out of date
ScriptLogicOutOfDate,
/// Our script logic is out of date (SVG)
ScriptSvgLogicOutOfDate,
/// Our script logic is out of date (script 1)
Script1LogicOutOfDate,
/// Our script logic is out of date (script 2)
Script2LogicOutOfDate,
/// Our script logic is out of date (xsid script)
ScriptXsidLogicOutOfDate,
/// Signature algorithm isn't working with inputs
BadSignatureInputs,
/// Something is broken with timeout math
TimeoutMathBroken,
/// Can't make request because cookies aren't present
NoCookiesPresent,
/// Unknown error generating video
ErrorGeneratingVideo,
// /// An error reading the file for upload.
// FileForUploadReadError(std::io::Error),
//
// /// The file path provided for upload is invalid.
// FileForUploadHasInvalidPath,
//
// /// Something is wrong with the JWT bearer token.
// /// This error originates on our end as we try to parse the JWT.
// LocalJwtClaimsParseError(String),
//
// /// There was an error constructing the form-multipart request.
// MultipartFormError(wreq::Error),
//
// /// We haven't received a bearer token yet
// /// This is our own internal application state error, not something Sora returns.
// /// We know our client can't make the request, so we preemptively fail it.
// NoBearerTokenForRequest,
//
// /// A sentinel token is not present in the client, which is required for some requests.
// NoSentinelTokenForRequest,
//
// /// Issue with using the SoraCredentialBuilder.
// SoraCredentialBuilderError(&'static str),
//
// /// Error parsing a request URL.
// UrlParseError(url::ParseError),
//
// /// Error serializing the sentinel store token to JSON (typically for persistent storage).
// CouldNotSerializeSentinelTokenStore(serde_json::Error),
//
// /// Error deserializing the sentinel token from JSON (typically for persistent storage).
// CouldNotDeserializeSentinelTokenStore {
// error: serde_json::Error,
// raw_json: String
// },
}
impl Error for GrokClientError {}
impl Display for GrokClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err),
Self::WebsocketRequestSerializationError(err) => write!(f, "Websocket request serialization error: {}", err),
Self::WebsocketLockError => write!(f, "Websocket lock error"),
Self::WebsocketReadError(err) => write!(f, "Websocket read error: {}", err),
Self::WebsocketSendError(err) => write!(f, "Websocket send error: {}", err),
Self::CannotOpenLocalFileForUpload(err) => write!(f, "Cannot open local file for upload: {}", err),
Self::CannotReadLocalFileForUpload(err) => write!(f, "Cannot read local file for upload: {}", err),
Self::CannotOpenLocalFileForWriting(err) => write!(f, "Cannot open local file for writing: {}", err),
Self::CannotWriteLocalFile(err) => write!(f, "Cannot write local file: {}", err),
Self::FileForUploadHasInvalidPath => write!(f, "File for upload has invalid path"),
Self::HtmlParsingError => write!(f, "Html parsing error"),
Self::FailedToDecodeVerificationToken(err) => write!(f, "Failed to decode verification token: {}", err),
Self::InvalidVerificationTokenBytes => write!(f, "Invalid verification token bytes"),
Self::ScriptLogicOutOfDate => write!(f, "Script logic out of date"),
Self::ScriptSvgLogicOutOfDate => write!(f, "Script logic out of date (SVG parsing)"),
Self::Script1LogicOutOfDate => write!(f, "Script logic out of date (script 1)"),
Self::Script2LogicOutOfDate => write!(f, "Script logic out of date (script 2)"),
Self::ScriptXsidLogicOutOfDate => write!(f, "Script logic out of date (xsid script)"),
Self::BadSignatureInputs => write!(f, "Bad signature inputs"),
Self::TimeoutMathBroken => write!(f, "Timeout math is broken"),
Self::NoCookiesPresent => write!(f, "No cookies present"),
Self::ErrorGeneratingVideo => write!(f, "Error generating video"),
//Self::FileForUploadReadError(err) => write!(f, "Error reading file for upload: {}", err),
//Self::FileForUploadHasInvalidPath => write!(f, "The file path provided for upload is invalid."),
//Self::LocalJwtClaimsParseError(msg) => write!(f, "Local JWT claims parse error: {}", msg),
//Self::MultipartFormError(err) => write!(f, "Multipart form error: {}", err),
//Self::NoBearerTokenForRequest => write!(f, "No bearer token available. The client needs a bearer token to make the request."),
//Self::NoSentinelTokenForRequest => write!(f, "No sentinel token available. The client needs a sentinel token to make the request."),
//Self::SoraCredentialBuilderError(msg) => write!(f, "Sora Credential Builder error: {}", msg),
//Self::UrlParseError(err) => write!(f, "URL parse error: {}", err),
//Self::CouldNotSerializeSentinelToken(err) => write!(f, "Could not serialize sentinel token to JSON: {}", err),
//Self::CouldNotSerializeSentinelTokenStore(err) => write!(f, "Could not serialize sentinel token store to JSON: {}", err),
//Self::CouldNotDeserializeSentinelTokenStore { error, raw_json} => {
// write!(f, "Could not deserialize sentinel token store from JSON: {:?}, raw JSON: {}", error, raw_json)
//},
}
}
}
@@ -1,53 +0,0 @@
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::error::grok_specific_api_error::GrokSpecificApiError;
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
#[derive(Debug)]
pub enum GrokError {
Client(GrokClientError),
ApiSpecific(GrokSpecificApiError),
ApiGeneric(GrokGenericApiError),
}
impl GrokError {
pub fn is_grok_having_downtime_issues(&self) -> bool {
match self {
Self::ApiGeneric(GrokGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true,
Self::ApiGeneric(GrokGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true,
Self::ApiGeneric(GrokGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true,
_ => false,
}
}
}
impl Error for GrokError {}
impl std::fmt::Display for GrokError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "GrokClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "GrokSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "GrokGenericApiError: {:?}", e),
}
}
}
impl From<GrokClientError> for GrokError {
fn from(error: GrokClientError) -> Self {
Self::Client(error)
}
}
impl From<GrokSpecificApiError> for GrokError {
fn from(error: GrokSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<GrokGenericApiError> for GrokError {
fn from(error: GrokGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -1,82 +0,0 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum GrokGenericApiError {
/// Specific Cloudflare errors.
CloudflareError(CloudflareError),
/// The downloaded index.html did not include the expected data.
/// This is necessary for handshakes between client and server.
IndexHtmlDidNotIncludeExpectedData {
message: String,
},
/// The downloaded javascript did not include the expected data.
/// This is necessary for handshakes between client and server.
ScriptBodyDidNotIncludeExpectedData {
message: String,
},
/// Error creating the websocket due to 401 Unauthorized, likely an authentication error with Grok.
/// Unfortunately we can't read the response body text to diagnose more because wreq has some API design issues.
LikelyWebsocketAuthentication401,
/// We received a 403 on websocket upgrade, likely from Cloudflare.
/// Unfortunately we can't read the response body text to diagnose more because wreq has some API design issues.
LikelyWebsocketCloudflare403,
/// Unknown status code on websocket upgrade.
UnexpectedWebsocketUpgradeStatusCode(StatusCode),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
/// Specifically, this is for non-200 (error) responses.
SerdeParseErrorWithBodyOnNon200(serde_json::Error, String),
/// An uncategorized bad HTTP response from Grok.
UncategorizedBadResponseWithStatus(StatusCode),
/// An uncategorized bad HTTP response from Grok.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// Our previous upload failed
UploadFailed,
/// An error upgrading the connection to a websocket.
WreqWebsocketUpgradeError(wreq::Error),
/// An uncaught error from the API client.
WreqError(wreq::Error),
}
impl Error for GrokGenericApiError {}
impl Display for GrokGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err),
Self::IndexHtmlDidNotIncludeExpectedData { message } => write!(f, "IndexHtmlDidNotIncludeExpectedData ( message: {} )", message),
Self::ScriptBodyDidNotIncludeExpectedData { message } => write!(f, "ScriptBodyDidNotIncludeExpectedData ( message: {} )", message),
Self::LikelyWebsocketAuthentication401 => write!(f, "Likely websocket authentication 401 error (probably Grok authentication issue with cookies)"),
Self::LikelyWebsocketCloudflare403 => write!(f, "Likely Cloudflare 403 error on websocket upgrade"),
Self::UnexpectedWebsocketUpgradeStatusCode(status) => write!(f, "Unexpected websocket upgrade status code: {}", status),
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponseWithStatus(status) => write!(f, "Uncategorized with status code: {}", status),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::UploadFailed => write!(f, "Upload failed"),
Self::WreqWebsocketUpgradeError(err) => write!(f, "Websocket upgrade error: {}", err),
Self::WreqError(err) => write!(f, "Wreq client error: {}", err),
}
}
}
@@ -1,78 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum GrokSpecificApiError {
/// Grok is detecting and blocking our bot, likely due to new secrets use.
AutomationBlocked,
/// Too many videos are being generated.
TooManyVideos,
// /// Failed to generate a bearer token despite the `200 OK` response.
// /// This has been generated by sending old/expired session cookies or empty cookies.
// FailedToGenerateBearerToken,
//
// /// The endpoint told us we used an invalid JWT.
// InvalidJwt,
//
// /// Sora has responded with "sentinel block".
// /// This usually means we need to refresh our sentinel token.
// SentinelBlockError,
//
// /// Sora requires users to set up an account username before they can generate images.
// /// This requires the user to set up that username.
// SoraUsernameNotYetCreated,
//
// /// Sora has responded with "token expired".
// /// This (probably?) means we need to refresh our JWT bearer token.
// TokenExpiredError,
//
// /// We're sending too many tasks to Sora.
// /// Sora is telling us to cool it.
// TooManyConcurrentTasks,
//
// // TODO: Try to make concrete error types for these distinct cases.
// /// Unauthorized, cookie and/or bearer token expired. We'll need to ask for a refreshed login.
// ///
// /// Example message, e.g. from the upload endpoint:
// /// {
// /// "error": {
// /// "message": "Your authentication token has expired. Please try signing in again.",
// /// "type": "invalid_request_error",
// /// "param": null,
// /// "code": "token_expired"
// /// }
// /// }
// UnauthorizedCookieOrBearerExpired,
//
// /// The Sora sentinel token response is missing fields
// SentinelResponseIsMissingFields {
// missing_token: bool,
// missing_turnstile: bool,
// raw_response: String,
// },
}
impl Error for GrokSpecificApiError {}
impl Display for GrokSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::AutomationBlocked => write!(f, "Automation blocked"),
Self::TooManyVideos => write!(f, "Too many videos are being generated"),
//Self::FailedToGenerateBearerToken => write!(f, "Failed to generate bearer token. Probably invalid or expired session cookies."),
//Self::InvalidJwt => write!(f, "Invalid JWT token. Please refresh the token."),
//Self::SentinelBlockError => write!(f, "Request blocked by Sentinel Token Block. Please refresh the sentinel token."),
//Self::SoraUsernameNotYetCreated => write!(f, "Sora username not yet created. Please create a username in the Sora app or website."),
//Self::TokenExpiredError => write!(f, "JWT token has expired. Please refresh the token."),
//Self::TooManyConcurrentTasks => write!(f, "Too many concurrent tasks. Please wait."),
//Self::UnauthorizedCookieOrBearerExpired => write!(f, "Unauthorized: cookie and/or bearer token expired"),
//Self::SentinelResponseIsMissingFields { missing_token: token_missing, missing_turnstile: turnstile_missing, raw_response } => {
// write!(f, "Sentinel response is missing fields. token_missing: {}, turnstile_missing: {}. Raw response: {}", token_missing, turnstile_missing, raw_response)
//},
}
}
}
@@ -1,5 +0,0 @@
pub mod grok_client_error;
pub mod grok_error;
pub mod grok_generic_api_error;
pub mod grok_specific_api_error;
pub (crate) mod categorize_grok_http_error;
@@ -1,15 +0,0 @@
//! A Grok API client.
#[cfg(test)]
pub (crate) mod test_utils;
// Library utils
pub (crate) mod client;
// User lib
pub mod credentials;
pub mod datatypes;
pub mod error;
pub mod recipes;
pub mod requests;
pub mod utils;
@@ -1,93 +0,0 @@
Upload Image -> Video Flow
==========================
1. Upload Image
Endpoint: https://grok.com/rest/app-chat/upload-file
Response Fields:
{
**"fileMetadataId": "21a79085-e206-4b0b-88ac-5f2b7a453e45",**
**"fileUri": "users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content",**
"fileMimeType": "image/jpeg",
"fileName": "0_0.jpeg",
"parsedFileUri": "",
"createTime": "2025-10-21T23:40:41.784448Z",
"fileSource": "SELF_UPLOAD_FILE_SOURCE"
}
File becomes a 480x640 copy:
https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content
2. "Create Media Post" **(needed?)**
Endpoint: https://grok.com/rest/media/post/create
Request Fields:
{
"mediaType": "MEDIA_POST_TYPE_IMAGE",
"mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content"
}
Response:
{
"post": {
"id": "21a79085-e206-4b0b-88ac-5f2b7a453e45",
"userId": "85980643-ffab-4984-a3de-59a608c47d7f",
"createTime": "2025-10-21T23:40:41.993980477Z",
"prompt": "",
"mediaType": "MEDIA_POST_TYPE_IMAGE",
"mediaUrl": "https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content",
"mimeType": "image/jpeg",
"audioUrls": [],
"childPosts": []
}
}
3. Chat Conversation for Video
Endpoint: https://grok.com/rest/app-chat/conversations/new
Request:
{
"temporary":true,
"modelName":"grok-3",
"message":"https://assets.grok.com/users/85980643-ffab-4984-a3de-59a608c47d7f/21a79085-e206-4b0b-88ac-5f2b7a453e45/content --mode=normal",
"fileAttachments":["21a79085-e206-4b0b-88ac-5f2b7a453e45"],
"toolOverrides": {
"videoGen":true
}
}
Header 'x-xai-request-id: 7cb93340-5fb6-4fb3-b88d-ff9f5d99d456' is just a random UUID and it can be changed.
Generate Image -> Video Flow
============================
(TODO)
2. Create Media Post
Request
{
"mediaType": "MEDIA_POST_TYPE_IMAGE",
"mediaUrl":"https://imagine-public.x.ai/imagine-public/images/2d8260d7-3b7c-4bc1-84ca-6e82dd892c39.png"
}
3. Chat Conversation for Video
Endpoint: https://grok.com/rest/app-chat/conversations/new
Request:
{
"temporary": true,
"modelName": "grok-3",
"message": "https://imagine-public.x.ai/imagine-public/images/2d8260d7-3b7c-4bc1-84ca-6e82dd892c39.png alien walks down the hallway --mode=custom",
"toolOverrides": {
"videoGen":true
}
}
@@ -1,4 +0,0 @@
pub mod prompt_websocket_image_with_retry;
pub mod request_client_secrets;
pub mod upload_image_and_generate_video;
pub mod upload_image_and_generate_video_with_retry;
@@ -1,201 +0,0 @@
use crate::credentials::grok_cookies::GrokCookies;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs};
use crate::requests::image_websocket::grok_websocket::GrokWebsocket;
use crate::requests::image_websocket::messages::websocket_client_message::{ClientMessageAspectRatio, WebsocketClientMessage};
use log::{error, info};
pub struct PromptWebsocketImageWithRetryArgs<'a> {
pub websocket: &'a mut GrokWebsocket,
pub prompt: &'a str,
pub aspect_ratio: ClientMessageAspectRatio,
pub cookies: &'a GrokCookies,
}
pub struct PromptWebsocketImageWithRetryResult {
pub maybe_new_websocket: Option<GrokWebsocket>,
}
pub async fn prompt_websocket_image_with_retry(args: PromptWebsocketImageWithRetryArgs<'_>) -> Result<PromptWebsocketImageWithRetryResult, GrokError>
{
let mut maybe_new_websocket : Option<GrokWebsocket> = None;
for i in 0..3 {
info!("Starting Grok Websocket image generation (attempt {}) ...", (i+1));
let message = WebsocketClientMessage::new_image_prompt(args.prompt, args.aspect_ratio);
let result =
if let Some(websocket) = maybe_new_websocket.as_mut() {
websocket.send_serializable(&message).await
} else {
args.websocket.send_serializable(&message).await
};
match result {
Ok(()) => {
break;
}
Err(GrokError::Client(GrokClientError::WreqClientError(err))) => {
error!("Error sending message to websocket (WreqClientError): {}", err);
}
Err(GrokError::Client(err)) => {
error!("Error sending message to websocket (GrokClientError): {}", err);
}
Err(GrokError::ApiSpecific(err)) => {
error!("Error sending message to websocket (GrokSpecificApiError): {}", err);
}
Err(GrokError::ApiGeneric(err)) => {
error!("Error sending message to websocket (GrokGenericApiError): {}", err);
}
}
let result = create_listen_websocket(CreateListenWebsocketArgs {
cookies: args.cookies.as_str(),
}).await?;
maybe_new_websocket = Some(GrokWebsocket::new(result));
}
Ok(PromptWebsocketImageWithRetryResult {
maybe_new_websocket,
})
}
#[cfg(test)]
mod tests {
use crate::recipes::prompt_websocket_image_with_retry::{prompt_websocket_image_with_retry, PromptWebsocketImageWithRetryArgs};
use crate::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs};
use crate::requests::image_websocket::grok_websocket::GrokWebsocket;
use crate::requests::image_websocket::grok_wrapped_websocket::GrokWrappedWebsocket;
use crate::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio;
use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage;
use crate::requests::image_websocket::prompt_websocket_image::{prompt_websocket_image, PromptWebsocketImageArgs};
use crate::test_utils::get_test_cookies::{get_test_cookies, get_typed_test_cookies};
use errors::AnyhowResult;
use futures::SinkExt;
use log::warn;
use std::io::Write;
use std::time::Duration;
use wreq::ws::message::{CloseCode, Message, Utf8Bytes};
#[tokio::test]
#[ignore] // manually test
async fn prompt() -> AnyhowResult<()> {
//setup_test_logging(LevelFilter::Trace);
let cookies = get_typed_test_cookies()?;
let websocket = create_listen_websocket(CreateListenWebsocketArgs {
cookies: cookies.as_str(),
}).await?;
let prompt = "A dinosaur on stilts walking on the beach";
//let websocket = GrokWrappedWebsocket::new(websocket);
let mut websocket = GrokWebsocket::new(websocket);
//println!("Sending...");
//std::io::stdout().flush()?;
//println!("For our test, we'll close the websocket and retry...");
//websocket.websocket
// .close(CloseCode::NORMAL, Utf8Bytes::try_from(Vec::new())?).await?;
//let mut websocket = GrokWebsocket::new(websocket.websocket);
let result = prompt_websocket_image_with_retry(PromptWebsocketImageWithRetryArgs {
websocket: &mut websocket,
prompt,
aspect_ratio: ClientMessageAspectRatio::WideThreeByTwo,
cookies: &cookies,
}).await?;
println!("Reading...");
std::io::stdout().flush()?;
let mut count = 0;
println!("Polling.");
loop {
//let maybe_message =
// websocket.get_response_with_timeout(Duration::from_millis(1000)).await?;
let maybe_message =
websocket.try_next_timeout(Duration::from_millis(1000)).await?;
let mut maybe_raw_text : Option<String> = None;
let maybe_message = match maybe_message {
None => None,
Some(Message::Text(text)) => {
let maybe_message = WebsocketServerMessage::from_json_str(text.as_str())?;
let mut text = text.as_str().to_string();
text.truncate(100);
//println!("Received message: {}", text);
maybe_raw_text = Some(text);
Some(maybe_message)
},
Some(_) => {
warn!("Received non-text websocket message.");
None
},
};
match maybe_message {
None => {
println!("No message received within timeout.");
count = count + 1;
}
Some(message) => {
match message {
WebsocketServerMessage::Image(image) => {
println!("IMAGE: {:?}", image.percentage_complete);
if let Some(percent) = image.percentage_complete {
if percent > 90.0 {
println!("Image: {:?}", image);
}
}
}
WebsocketServerMessage::Json(json) => {
println!("JSON : {:?}", json.percentage_complete);
if let Some(percent) = json.percentage_complete {
if percent > 90.0 {
println!("Image: {:?}", json);
}
}
}
WebsocketServerMessage::Unknown(unknown) => {
//let typ = unknown.get("type");
let unknown_string = unknown.as_str();
println!("[UNKNOWN] websocket message: {:?}", maybe_raw_text);
}
}
count = 0;
}
}
// TODO: Make sure we capture all the events for images
// TODO: Explore the other APIs
// TODO understand the IDs
// TODO: Video websocket APIs
// TODO: Upload APIs
if count > 5 {
println!("No messages after 5 seconds");
break;
}
}
println!("Done polling.");
log::logger().flush();
assert_eq!(1,2);
Ok(())
}
}
@@ -1,124 +0,0 @@
use crate::credentials::grok_client_secrets::GrokClientSecrets;
use crate::credentials::grok_cookies::GrokCookies;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::requests::index_page::get_index_page_and_scripts::{get_index_page_and_scripts, GetIndexPageAndScriptsArgs};
use crate::requests::index_page::get_xsid_script::{get_xsid_script, GetXsidScriptArgs};
use crate::requests::index_page::parsers::index::parse_index_baggage::parse_index_baggage;
use crate::requests::index_page::parsers::index::parse_index_sentry_trace::parse_index_sentry_trace;
use crate::requests::index_page::parsers::index::parse_index_svg_paths::parse_svg_paths_from_index_html;
use crate::requests::index_page::parsers::index::parse_index_user_email::parse_index_user_email;
use crate::requests::index_page::parsers::index::parse_index_user_id::parse_index_user_id;
use crate::requests::index_page::parsers::index::parse_index_verification_token::parse_index_verification_token;
use crate::requests::index_page::parsers::script::parse_script_actions_and_xsid_script_path::parse_script_actions_and_xsid_script_path;
use crate::requests::index_page::parsers::script::parse_xsid_script_numbers::parse_xsid_script_numbers;
use crate::requests::index_page::utils::convert_verification_token_to_loading_anim::convert_verification_token_to_loading_anim;
use crate::requests::index_page::utils::select_svg_path_by_loading_anim::select_svg_path_by_loading_anim;
pub struct RequestClientSecretsArgs<'a> {
pub cookies: &'a GrokCookies,
}
// Video generation needs:
// 1) baggage -- on index page
// 2) sentry_trace -- on index page
// 3) x_statsig
/// Load all the things needed to make requests.
pub async fn request_client_secrets(args: RequestClientSecretsArgs<'_>) -> Result<GrokClientSecrets, GrokError> {
let payloads = get_index_page_and_scripts(GetIndexPageAndScriptsArgs {
cookie: args.cookies.as_str(),
}).await?;
let baggage = parse_index_baggage(&payloads.index_body_html)
.ok_or_else(|| GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData {
message: "Index did not include baggage.".to_string()
})?;
let sentry_trace = parse_index_sentry_trace(&payloads.index_body_html)
.ok_or_else(|| GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData {
message: "Index did not include sentry trace.".to_string()
})?;
let verification_token = parse_index_verification_token(&payloads.index_body_html)
.ok_or_else(|| GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData {
message: "Index did not include verification token.".to_string()
})?;
let user_id = parse_index_user_id(&payloads.index_body_html)
.ok_or_else(|| GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData {
message: "Index did not include user id.".to_string()
})?;
// NB: Optional.
let maybe_user_email = parse_index_user_email(&payloads.index_body_html);
// NB: We'll only use one of the several SVG paths
let svg_paths = parse_svg_paths_from_index_html(&payloads.index_body_html);
if svg_paths.is_empty() {
return Err(GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData {
message: "Index did not include any SVG paths.".to_string(),
}.into());
}
let loading_anim = convert_verification_token_to_loading_anim(&verification_token)?;
let svg_path_data = select_svg_path_by_loading_anim(&svg_paths, &loading_anim)?;
let actions_and_xsid_script = parse_script_actions_and_xsid_script_path(&payloads.scripts)?;
let xsid_script = get_xsid_script(GetXsidScriptArgs {
client: &payloads.client,
html: &payloads.index_body_html,
cookie: args.cookies.as_str(),
xsid_script_id: &actions_and_xsid_script.xsid_script_path,
}).await?;
let numbers = parse_xsid_script_numbers(&xsid_script.xsid_script_body);
if numbers.numbers.is_empty() {
return Err(GrokGenericApiError::IndexHtmlDidNotIncludeExpectedData {
message: "Index did not include any SVG paths.".to_string(),
}.into());
}
Ok(GrokClientSecrets {
baggage,
sentry_trace,
verification_token,
svg_path_data,
numbers,
user_id,
user_email: maybe_user_email,
})
}
#[cfg(test)]
mod tests {
use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs};
use crate::test_utils::get_test_cookies::get_typed_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookies = get_typed_test_cookies()?;
let secrets = request_client_secrets(RequestClientSecretsArgs {
cookies: &cookies,
}).await?;
println!("Baggage: {:?}", secrets.baggage);
println!("Sentry trace: {:?}", secrets.sentry_trace);
println!("Verification token: {:?}", secrets.verification_token);
println!("SVG path: {:?}", secrets.svg_path_data);
println!("Numbers: {:?}", secrets.numbers);
println!("User ID: {:?}", secrets.user_id);
println!("User Email: {:?}", secrets.user_email);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,242 +0,0 @@
use crate::credentials::grok_full_credentials::GrokFullCredentials;
use crate::datatypes::api::aspect_ratio::AspectRatio;
use crate::datatypes::api::file_id::FileId;
use crate::datatypes::api::post_id::PostId;
use crate::datatypes::api::video_generation_mode::VideoGenerationMode;
use crate::datatypes::file_upload_spec::FileUploadSpec;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::requests::media_posts::create_media_post::grok_create_media_post::{GrokCreateMediaPost, MediaPostType};
use crate::requests::media_posts::like_media_post::grok_like_media::GrokLikeMediaPost;
use crate::requests::upload_file::grok_upload_file::GrokUploadFile;
use crate::requests::video_chat::grok_video_gen_chat_conversation::{GrokVideoGenChatConversationBuilder, VideoMediaPostType};
use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url;
use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url;
use log::{error, info};
use std::path::Path;
use std::time::Duration;
pub struct UploadImageAndGenerateVideo<'a, P: AsRef<Path>> {
pub full_credentials: &'a GrokFullCredentials,
// NB: Must be owned.
pub file: FileUploadSpec<P>,
/// Video generation prompt
pub prompt: Option<&'a str>,
/// Aspect ratio for the video.
pub aspect_ratio: Option<AspectRatio>,
/// Mode to generate the video: normal, fun, spicy, etc.
pub mode: Option<VideoGenerationMode>,
/// Wait for the full video to be generated before returning
/// By default, the endpoint stays open for 30-ish seconds while
/// the video generates and streams back JSON progress updates.
/// If we set this to false, we'll wait for the video ID and exit
/// the request early, before the video is finished asynchronously
/// generating.
pub wait_for_generation: bool,
pub individual_request_timeout: Option<Duration>,
}
#[derive(Debug, Clone)]
pub struct ImageUploadAndGenerateVideoResult {
/// The ID of the "post"
/// This often is equivalent to the upload id / `image_file_id`,
/// and this is in the user-facing URL for website navigation to the video.
pub post_id: PostId,
/// The ID of the file we uploaded
pub image_file_id: FileId,
/// The ID of the video we're generating, which may or may not exist yet.
pub video_file_id: Option<FileId>,
/// The URL for the video, which may or may not exist yet.
pub video_url: Option<String>,
/// Whether the video generation has already completed.
/// Based on request `wait_for_generation`.
pub generation_is_complete: bool,
}
pub async fn upload_image_and_generate_video<P: AsRef<Path>>(args: UploadImageAndGenerateVideo<'_, P>)
-> Result<ImageUploadAndGenerateVideoResult, GrokError>
{
info!("Uploading file to Grok...");
let request = GrokUploadFile {
file: args.file,
cookie: args.full_credentials.cookies.to_string(),
request_timeout: args.individual_request_timeout,
};
let upload_result = request.upload().await?;
info!("File URI: {:?}", upload_result.file_uri);
info!("File Metadata ID: {:?}", upload_result.file_id);
let upload_file_id = match upload_result.file_id {
Some(id) => id,
None => {
error!("Previous file upload failed. Cannot continue.");
return Err(GrokGenericApiError::UploadFailed.into());
}
};
let url = user_and_file_id_to_image_url(
&args.full_credentials.client_secrets.user_id,
&upload_file_id
);
info!("Uploaded URI: {:?}", url);
info!("Creating media post...");
let request = GrokCreateMediaPost {
user_id: &args.full_credentials.client_secrets.user_id,
file_id: &upload_file_id,
media_type: MediaPostType::UserUploadedImage,
cookie: args.full_credentials.cookies.as_str(),
request_timeout: args.individual_request_timeout,
};
let post_result = request.send().await?;
info!("Posted Image.");
info!("Video prompt: {:?}", args.prompt);
info!("Video aspect ratio: {:?}", args.aspect_ratio);
info!("Generate video...");
let request = GrokVideoGenChatConversationBuilder {
user_id: &args.full_credentials.client_secrets.user_id,
file_id: &upload_file_id,
media_type: VideoMediaPostType::UserUploadedImage,
cookie: args.full_credentials.cookies.as_str(),
prompt: args.prompt,
mode: args.mode,
aspect_ratio: args.aspect_ratio,
request_timeout: args.individual_request_timeout,
baggage: &args.full_credentials.client_secrets.baggage,
sentry_trace: &args.full_credentials.client_secrets.sentry_trace,
verification_token: &args.full_credentials.client_secrets.verification_token,
svg_data: &args.full_credentials.client_secrets.svg_path_data,
numbers: &args.full_credentials.client_secrets.numbers,
};
let maybe_video_file_id;
let generation_is_complete;
if args.wait_for_generation {
info!("Waiting for video generation...");
let video_gen_result = request.wait_for_video().await?;
maybe_video_file_id = video_gen_result.video_file_id;
generation_is_complete = true;
} else {
info!("Streaming response partially...");
let video_gen_result = request.stream_only_video_id().await?;
maybe_video_file_id = video_gen_result.video_file_id;
generation_is_complete = false;
}
let maybe_video_url = maybe_video_file_id
.as_ref()
.map(|file_id| {
user_and_file_id_to_video_url(&args.full_credentials.client_secrets.user_id, file_id, false)
});
info!("Video Generation Enqueued");
info!("Liking media...");
let request = GrokLikeMediaPost {
file_id: &upload_file_id,
cookie: args.full_credentials.cookies.as_str(),
request_timeout: args.individual_request_timeout,
baggage: &args.full_credentials.client_secrets.baggage,
sentry_trace: &args.full_credentials.client_secrets.sentry_trace,
verification_token: &args.full_credentials.client_secrets.verification_token,
svg_data: &args.full_credentials.client_secrets.svg_path_data,
numbers: &args.full_credentials.client_secrets.numbers,
};
let _like_result = request.send().await?;
info!("Media Liked");
Ok(ImageUploadAndGenerateVideoResult {
post_id: post_result.post_id,
image_file_id: upload_file_id,
video_file_id: maybe_video_file_id,
video_url: maybe_video_url,
generation_is_complete,
})
}
#[cfg(test)]
mod tests {
use crate::credentials::grok_full_credentials::GrokFullCredentials;
use crate::datatypes::api::aspect_ratio::AspectRatio;
use crate::datatypes::file_upload_spec::FileUploadSpec;
use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs};
use crate::recipes::upload_image_and_generate_video::{upload_image_and_generate_video, UploadImageAndGenerateVideo};
use crate::test_utils::get_test_cookies::get_typed_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
// Result: GrokUploadFileResponse { file_metadata_id:
// Some("acdee48f-9d6f-4bc6-9d06-fcc97dd4418a"), file_uri:
// Some("users/85980643-ffab-4984-a3de-59a608c47d7f/acdee48f-9d6f-4bc6-9d06-fcc97dd4418a/content") }
#[tokio::test]
#[ignore] // Client side tests only
async fn test_upload_image_and_generate_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
//let image_path = "/Users/bt/Pictures/Zelda 64 Art/FCgYX6tWEAEhpsy.jpg";
//let prompt = "our hero link plunges the sword into the pedestal, the temple is glowing with a blue aura";
let image_path = "/Users/bt/Pictures/Midjourney/hipster_girl.png";
let maybe_prompt = Some("an anime girl runs away from a giant t-rex");
let cookies = get_typed_test_cookies()?;
let secrets = request_client_secrets(RequestClientSecretsArgs {
cookies: &cookies,
}).await?;
println!("[test] Verification Token: {:?}", secrets.verification_token);
println!("[test] Sentry Trace: {:?}", secrets.sentry_trace);
println!("[test] Numbers: {:?}", secrets.numbers);
println!("[test] Svg Path: {:?}", secrets.svg_path_data);
println!("[test] Baggage: {:?}", secrets.baggage);
let credentials = GrokFullCredentials::from_cookies_and_client_secrets(cookies, secrets);
let video_result = upload_image_and_generate_video(UploadImageAndGenerateVideo {
full_credentials: &credentials,
file: FileUploadSpec::Path(image_path),
prompt: maybe_prompt,
aspect_ratio: Some(AspectRatio::Square),
mode: None,
individual_request_timeout: None,
wait_for_generation: false,
}).await?;
println!("[test] Post ID: {:?}", video_result.post_id);
println!("[test] Image File ID: {:?}", video_result.image_file_id);
println!("[test] Video File ID: {:?}", video_result.video_file_id);
println!("[test] Video URL: {:?}", video_result.video_url);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,348 +0,0 @@
use crate::credentials::grok_client_secrets::GrokClientSecrets;
use crate::credentials::grok_full_credentials::GrokFullCredentials;
use crate::datatypes::api::aspect_ratio::AspectRatio;
use crate::datatypes::api::video_generation_mode::VideoGenerationMode;
use crate::datatypes::file_upload_spec::FileUploadSpec;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::error::grok_specific_api_error::GrokSpecificApiError;
use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs};
use crate::recipes::upload_image_and_generate_video::{ImageUploadAndGenerateVideoResult, UploadImageAndGenerateVideo};
use crate::requests::media_posts::create_media_post::grok_create_media_post::{GrokCreateMediaPost, MediaPostType};
use crate::requests::media_posts::like_media_post::grok_like_media::GrokLikeMediaPost;
use crate::requests::upload_file::grok_upload_file::GrokUploadFile;
use crate::requests::video_chat::grok_video_gen_chat_conversation::{GrokVideoGenChatConversationBuilder, VideoMediaPostType};
use crate::utils::user_and_file_id_to_image_url::user_and_file_id_to_image_url;
use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url;
use log::{error, info};
use std::path::Path;
use std::time::Duration;
pub struct UploadImageAndGenerateVideoWithRetry<'a, P: AsRef<Path>> {
pub credentials: &'a GrokFullCredentials,
// NB: Must be owned.
pub file: FileUploadSpec<P>,
/// Video generation prompt
pub prompt: Option<&'a str>,
/// Aspect ratio for the video.
pub aspect_ratio: Option<AspectRatio>,
/// Mode to generate the video: normal, fun, spicy, etc.
pub mode: Option<VideoGenerationMode>,
/// Wait for the full video to be generated before returning
/// By default, the endpoint stays open for 30-ish seconds while
/// the video generates and streams back JSON progress updates.
/// If we set this to false, we'll wait for the video ID and exit
/// the request early, before the video is finished asynchronously
/// generating.
pub wait_for_generation: bool,
pub individual_request_timeout: Option<Duration>,
}
#[derive(Clone)]
pub struct ImageUploadAndGenerateVideoWithRetryResult {
/// If new secrets were generated, they're stored here.
pub maybe_new_client_secrets: Option<GrokClientSecrets>,
/// The video generation result.
pub upload_result: ImageUploadAndGenerateVideoResult,
}
pub async fn upload_image_and_generate_video_with_retry<P: AsRef<Path>>(
args: UploadImageAndGenerateVideoWithRetry<'_, P>
) -> Result<ImageUploadAndGenerateVideoWithRetryResult, GrokError> {
let mut current_full_credentials_ref = args.credentials;
let mut maybe_new_credentials = None;
info!("Uploading file to Grok...");
let request = GrokUploadFile {
file: args.file,
cookie: current_full_credentials_ref.cookies.to_string(),
request_timeout: args.individual_request_timeout,
};
let upload_result = request.upload().await?;
info!("File URI: {:?}", upload_result.file_uri);
info!("File Metadata ID: {:?}", upload_result.file_id);
let upload_file_id = match upload_result.file_id {
Some(id) => id,
None => {
error!("Previous file upload failed. Cannot continue.");
return Err(GrokGenericApiError::UploadFailed.into());
}
};
let url = user_and_file_id_to_image_url(
&current_full_credentials_ref.client_secrets.user_id,
&upload_file_id
);
info!("Uploaded URI: {:?}", url);
info!("Creating media post...");
let request = GrokCreateMediaPost {
user_id: &current_full_credentials_ref.client_secrets.user_id,
file_id: &upload_file_id,
media_type: MediaPostType::UserUploadedImage,
cookie: current_full_credentials_ref.cookies.as_str(),
request_timeout: args.individual_request_timeout,
};
let post_result = request.send().await?;
info!("Posted Image.");
info!("Video prompt: {:?}", args.prompt);
info!("Video aspect ratio: {:?}", args.aspect_ratio);
let mut video_enqueued_successfully = false;
let mut generation_is_complete= false;
let mut maybe_video_file_id = None;
let mut last_error = None;
for i in 0..3 {
info!("Generate video... attempt {}", (i+1));
let request = GrokVideoGenChatConversationBuilder {
user_id: &current_full_credentials_ref.client_secrets.user_id,
file_id: &upload_file_id,
media_type: VideoMediaPostType::UserUploadedImage,
cookie: current_full_credentials_ref.cookies.as_str(),
prompt: args.prompt,
mode: args.mode,
aspect_ratio: args.aspect_ratio,
request_timeout: args.individual_request_timeout,
baggage: &current_full_credentials_ref.client_secrets.baggage,
sentry_trace: &current_full_credentials_ref.client_secrets.sentry_trace,
verification_token: &current_full_credentials_ref.client_secrets.verification_token,
svg_data: &current_full_credentials_ref.client_secrets.svg_path_data,
numbers: &current_full_credentials_ref.client_secrets.numbers,
};
let video_gen_result =
if args.wait_for_generation {
request.wait_for_video()
.await
.map(|res| res.video_file_id)
} else {
request.stream_only_video_id()
.await
.map(|res| res.video_file_id)
};
match video_gen_result {
Ok(res) => {
info!("Video Generation Enqueued!");
video_enqueued_successfully = true;
generation_is_complete = args.wait_for_generation; // If we synchronously waited, it'll be complete.
maybe_video_file_id = res;
break;
}
Err(GrokError::ApiSpecific(GrokSpecificApiError::AutomationBlocked)) => {
info!("Grok automation blocked; renewing credentials.");
last_error = Some(GrokError::ApiSpecific(GrokSpecificApiError::AutomationBlocked));
}
Err(err) => {
info!("Grok error; renewing credentials. Error = {:?}", err);
last_error = Some(err);
}
}
info!("Refreshing Grok client secrets...");
let secrets = request_client_secrets(RequestClientSecretsArgs {
cookies: &current_full_credentials_ref.cookies,
}).await?;
// NB: This is just to appease the borrow checker. It doesn't like that we're borrowing `maybe_new_credentials`
// in a loop and replacing it while borrowed, hence this hack.
current_full_credentials_ref = args.credentials;
maybe_new_credentials = Some(GrokFullCredentials::from_cookies_and_client_secrets(
current_full_credentials_ref.cookies.clone(),
secrets,
));
if let Some(creds) = maybe_new_credentials.as_ref() {
current_full_credentials_ref = creds;
}
}
if !video_enqueued_successfully {
if let Some(err) = last_error {
return Err(err);
} else {
// NB: This branch should be impossible as we should always set last_error.
return Err(GrokError::Client(GrokClientError::ErrorGeneratingVideo))
}
}
let mut video_liked_successfully = false;
let mut last_error = None;
for i in 0..3 {
info!("Liking media ... attempt {}", (i+1));
let request = GrokLikeMediaPost {
file_id: &upload_file_id,
cookie: current_full_credentials_ref.cookies.as_str(),
request_timeout: args.individual_request_timeout,
baggage: &current_full_credentials_ref.client_secrets.baggage,
sentry_trace: &current_full_credentials_ref.client_secrets.sentry_trace,
verification_token: &current_full_credentials_ref.client_secrets.verification_token,
svg_data: &current_full_credentials_ref.client_secrets.svg_path_data,
numbers: &current_full_credentials_ref.client_secrets.numbers,
};
let like_result = request.send().await;
match like_result {
Ok(_) => {
info!("Media Liked");
video_liked_successfully = true;
break;
}
Err(GrokError::ApiSpecific(GrokSpecificApiError::AutomationBlocked)) => {
info!("Grok automation blocked liking media; renewing credentials.");
last_error = Some(GrokError::ApiSpecific(GrokSpecificApiError::AutomationBlocked));
}
Err(err) => {
info!("Grok error liking media; renewing credentials. Error = {:?}", err);
last_error = Some(err);
}
}
info!("Refreshing Grok client secrets...");
let secrets = request_client_secrets(RequestClientSecretsArgs {
cookies: &current_full_credentials_ref.cookies,
}).await?;
// NB: This is just to appease the borrow checker. It doesn't like that we're borrowing `maybe_new_credentials`
// in a loop and replacing it while borrowed, hence this hack.
current_full_credentials_ref = args.credentials;
maybe_new_credentials = Some(GrokFullCredentials::from_cookies_and_client_secrets(
current_full_credentials_ref.cookies.clone(),
secrets,
));
if let Some(creds) = maybe_new_credentials.as_ref() {
current_full_credentials_ref = creds;
}
}
if !video_liked_successfully {
if let Some(err) = last_error {
return Err(err);
} else {
// NB: This branch should be impossible as we should always set last_error.
return Err(GrokError::Client(GrokClientError::ErrorGeneratingVideo))
}
}
let maybe_video_url = maybe_video_file_id
.as_ref()
.map(|file_id| {
user_and_file_id_to_video_url(&current_full_credentials_ref.client_secrets.user_id, file_id, false)
});
Ok(ImageUploadAndGenerateVideoWithRetryResult {
maybe_new_client_secrets: maybe_new_credentials
.map(|secrets| secrets.client_secrets),
upload_result: ImageUploadAndGenerateVideoResult {
post_id: post_result.post_id,
image_file_id: upload_file_id,
video_file_id: maybe_video_file_id,
video_url: maybe_video_url,
generation_is_complete,
}
})
}
#[cfg(test)]
mod tests {
use crate::credentials::grok_full_credentials::GrokFullCredentials;
use crate::datatypes::api::aspect_ratio::AspectRatio;
use crate::datatypes::api::video_generation_mode::VideoGenerationMode;
use crate::datatypes::file_upload_spec::FileUploadSpec;
use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs};
use crate::recipes::upload_image_and_generate_video_with_retry::{upload_image_and_generate_video_with_retry, UploadImageAndGenerateVideoWithRetry};
use crate::test_utils::get_test_cookies::get_typed_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
// Result: GrokUploadFileResponse { file_metadata_id:
// Some("acdee48f-9d6f-4bc6-9d06-fcc97dd4418a"), file_uri:
// Some("users/85980643-ffab-4984-a3de-59a608c47d7f/acdee48f-9d6f-4bc6-9d06-fcc97dd4418a/content") }
#[tokio::test]
#[ignore] // Client side tests only
async fn test_upload_image_and_generate_video() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
//let image_path = "/Users/bt/Pictures/Zelda 64 Art/FCgYX6tWEAEhpsy.jpg";
//let prompt = "our hero link plunges the sword into the pedestal, the temple is glowing with a blue aura";
let image_path = "/Users/bt/Pictures/Midjourney/hipster_girl.png";
let maybe_prompt = Some("an anime girl turns and high fives a giant t-rex");
let cookies = get_typed_test_cookies()?;
let mut bad_secrets = request_client_secrets(RequestClientSecretsArgs {
cookies: &cookies,
}).await?;
// NB: We're purposely messing these up to trigger "retry".
bad_secrets.numbers.numbers = Vec::new();
bad_secrets.sentry_trace.0 = "".to_string();
let credentials = GrokFullCredentials::from_cookies_and_client_secrets(cookies, bad_secrets);
let result = upload_image_and_generate_video_with_retry(UploadImageAndGenerateVideoWithRetry {
credentials: &credentials,
file: FileUploadSpec::Path(image_path),
prompt: maybe_prompt,
mode: Some(VideoGenerationMode::Custom),
aspect_ratio: Some(AspectRatio::TallTwoByThree),
individual_request_timeout: None,
wait_for_generation: false,
}).await?;
let new_secrets = result.maybe_new_client_secrets;
println!("[test] New secrets ? : {:?}", new_secrets.is_some());
if let Some(secrets) = new_secrets {
println!("[test] Verification Token: {:?}", secrets.verification_token);
println!("[test] Sentry Trace: {:?}", secrets.sentry_trace);
println!("[test] Numbers: {:?}", secrets.numbers);
println!("[test] Svg Path: {:?}", secrets.svg_path_data);
println!("[test] Baggage: {:?}", secrets.baggage);
}
let video_result = result.upload_result;
println!("[test] Post ID: {:?}", video_result.post_id);
println!("[test] Image File ID: {:?}", video_result.image_file_id);
println!("[test] Video File ID: {:?}", video_result.video_file_id);
println!("[test] Video URL: {:?}", video_result.video_url);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,128 +0,0 @@
use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT;
use crate::datatypes::api::file_id::FileId;
use crate::datatypes::api::user_id::UserId;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url;
use log::{error, info, warn};
use std::time::Duration;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER, TE, UPGRADE_INSECURE_REQUESTS, USER_AGENT};
use wreq::Client;
use wreq_util::Emulation;
pub struct DownloadVideoFileArgs<'a> {
pub cookies: &'a str,
pub user_id: &'a UserId,
pub file_id: &'a FileId,
pub request_timeout: Option<Duration>,
}
pub struct VideoFile {
pub bytes: Vec<u8>,
}
pub async fn download_video_file(args: DownloadVideoFileArgs<'_>) -> Result<VideoFile, GrokError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| GrokClientError::WreqClientError(err))?;
let video_url = user_and_file_id_to_video_url(&args.user_id, &args.file_id, false);
info!("Video file URL: {}", video_url);
let mut request_builder = client.get(&video_url)
.header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT)
.header(ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, "https://grok.com/")
.header("Sec-GPC", "1")
.header(CONNECTION, "keep-alive")
.header(COOKIE, args.cookies)
.header(UPGRADE_INSECURE_REQUESTS, "1")
.header("sec-fetch-dest", "document")
.header("sec-fetch-mode", "navigate")
.header("sec-fetch-site", "same-site")
.header("sec-fetch-user", "?1")
.header("priority", "u=0, i");
if let Some(timeout) = args.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send()
.await
.map_err(|err| GrokGenericApiError::WreqError(err))?;
let status = response.status();
info!("Download video status: {:?}", status);
let download_bytes = &response.bytes()
.await
.map_err(|err| {
error!("Error reading Grok create media response body: {:?}", err);
GrokGenericApiError::WreqError(err)
})?;
// TODO: Classify errors
if !status.is_success() {
warn!("Not successful liking media (status: {:?})", status);
// error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body);
// return Err(classify_general_http_status_code_and_body(status, response_body));
}
Ok(VideoFile {
bytes: download_bytes.to_vec(),
})
}
#[cfg(test)]
mod tests {
use std::fs::File;
use std::io::Write;
use crate::datatypes::api::file_id::FileId;
use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs};
use crate::requests::download_video_file::download_video_file::{download_video_file, DownloadVideoFileArgs};
use crate::test_utils::get_test_cookies::get_typed_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Client side tests only
async fn test_download() -> AnyhowResult<()> {
//setup_test_logging(LevelFilter::Trace);
let cookies = get_typed_test_cookies()?;
let file_id = FileId("d9b300c6-9562-4e24-a87a-3ede2a53f0bc".to_string()); // Ernest
let secrets = request_client_secrets(RequestClientSecretsArgs {
cookies: &cookies,
}).await?;
println!("Verification Token: {:?}", secrets.verification_token);
println!("Sentry Trace: {:?}", secrets.sentry_trace);
println!("Numbers: {:?}", secrets.numbers);
println!("Svg Path: {:?}", secrets.svg_path_data);
println!("Baggage: {:?}", secrets.baggage);
let download = download_video_file(DownloadVideoFileArgs {
cookies: cookies.as_str(),
user_id: &secrets.user_id,
file_id: &file_id,
request_timeout: None,
}).await?;
println!("Video byte length: {:?}", download.bytes.len());
let mut file = File::create("download_video_file.test.mp4")?;
file.write_all(&download.bytes)?;
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,171 +0,0 @@
use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT;
use crate::datatypes::api::file_id::FileId;
use crate::datatypes::api::user_id::UserId;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use crate::utils::user_and_file_id_to_video_url::user_and_file_id_to_video_url;
use futures::{StreamExt, TryStreamExt};
use log::{error, info, warn};
use std::io::Write;
use std::path::Path;
use std::time::Duration;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER, TE, UPGRADE_INSECURE_REQUESTS, USER_AGENT};
use wreq::{Client, Request, Response};
use wreq_util::Emulation;
pub struct GrokDownloadVideo<'a> {
pub cookies: &'a str,
pub user_id: &'a UserId,
pub file_id: &'a FileId,
pub request_timeout: Option<Duration>,
}
pub struct DownloadedVideoBytes {
pub bytes: Vec<u8>,
}
impl <'a> GrokDownloadVideo<'a> {
pub async fn download_to_path<P: AsRef<Path>>(&self, path: P) -> Result<(), GrokError> {
let response = self.send_request().await?;
let mut stream = response.bytes_stream();
let mut file = File::create(&path)
.await
.map_err(|err| GrokClientError::CannotOpenLocalFileForWriting(err))?;
//let _r = stream.for_each_concurrent(None, |chunk_result| async {
// match chunk_result {
// Ok(chunk) => {
// file;
// //if let Err(e) = file.write_all(&chunk).await {
// // //error!("Error writing to file: {}", e);
// //}
// }
// Err(e) => {
// //error!("Error in stream: {}", e);
// }
// }
//}).await;
while let Some(chunk) = stream.try_next()
.await
.map_err(|err| GrokGenericApiError::WreqError(err))?
{
file.write_all(&chunk)
.await
.map_err(|err| GrokClientError::CannotOpenLocalFileForWriting(err))?
}
file.flush().await.unwrap();
Ok(())
}
pub async fn download_bytes(&self) -> Result<DownloadedVideoBytes, GrokError> {
let response = self.send_request().await?;
let download_bytes = response.bytes()
.await
.map_err(|err| {
error!("Error reading Grok create media response body: {:?}", err);
GrokGenericApiError::WreqError(err)
})?;
Ok(DownloadedVideoBytes {
bytes: download_bytes.to_vec(),
})
}
async fn send_request(&self) -> Result<Response, GrokError> {
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| GrokClientError::WreqClientError(err))?;
let video_url = user_and_file_id_to_video_url(&self.user_id, &self.file_id, false);
info!("Video file URL: {}", video_url);
let mut request_builder = client.get(&video_url)
.header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT)
.header(ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header(ACCEPT_ENCODING, "gzip, deflate, br, zstd")
.header(REFERER, "https://grok.com/")
.header("Sec-GPC", "1")
.header(CONNECTION, "keep-alive")
.header(COOKIE, self.cookies.to_string())
.header(UPGRADE_INSECURE_REQUESTS, "1")
.header("sec-fetch-dest", "document")
.header("sec-fetch-mode", "navigate")
.header("sec-fetch-site", "same-site")
.header("sec-fetch-user", "?1")
.header("priority", "u=0, i");
if let Some(timeout) = self.request_timeout {
request_builder = request_builder.timeout(timeout);
}
let response = request_builder.send()
.await
.map_err(|err| GrokGenericApiError::WreqError(err))?;
let status = response.status();
// TODO: Classify errors
if !status.is_success() {
warn!("Not successful liking media (status: {:?})", status);
// error!("Upload file request returned an error (code {}) : {:?}", status.as_u16(), response_body);
// return Err(classify_general_http_status_code_and_body(status, response_body));
}
Ok(response)
}
}
#[cfg(test)]
mod tests {
use crate::datatypes::api::file_id::FileId;
use crate::recipes::request_client_secrets::{request_client_secrets, RequestClientSecretsArgs};
use crate::requests::download_video_file::download_video_file::{download_video_file, DownloadVideoFileArgs};
use crate::test_utils::get_test_cookies::get_typed_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Client side tests only
async fn test_download() -> AnyhowResult<()> {
//setup_test_logging(LevelFilter::Trace);
let cookies = get_typed_test_cookies()?;
let file_id = FileId("d9b300c6-9562-4e24-a87a-3ede2a53f0bc".to_string()); // Ernest
let secrets = request_client_secrets(RequestClientSecretsArgs {
cookies: &cookies,
}).await?;
println!("Verification Token: {:?}", secrets.verification_token);
println!("Sentry Trace: {:?}", secrets.sentry_trace);
println!("Numbers: {:?}", secrets.numbers);
println!("Svg Path: {:?}", secrets.svg_path_data);
println!("Baggage: {:?}", secrets.baggage);
// TDOO: This implementation is untested.
//let download = download_video_file(DownloadVideoFileArgs {
// cookies: cookies.as_str(),
// user_id: &secrets.user_id,
// file_id: &file_id,
// request_timeout: None,
//}).await?;
//println!("Video byte length: {:?}", download.bytes.len());
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,2 +0,0 @@
pub mod download_video_file;
pub mod grok_download_video;
@@ -1,81 +0,0 @@
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use std::time::Duration;
use wreq::header::{HeaderMap, USER_AGENT};
use wreq::{Client, Proxy};
use wreq_util::Emulation;
const FINGERPRINT_URL : &str = "https://tools.scrapfly.io/api/tls";
fn build_chrome_140_client() -> Result<Client, GrokError> {
Ok(Client::builder()
.emulation(Emulation::Chrome140)
.connection_verbose(true)
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|err| GrokClientError::WreqClientError(err))?)
}
fn build_firefox_139_client() -> Result<Client, GrokError> {
Ok(Client::builder()
.emulation(Emulation::Firefox139) // NB: THIS IS IDENTICAL IN FINGERPRINT TO REAL FIREFOX !!!
.connection_verbose(true)
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|err| GrokClientError::WreqClientError(err))?)
}
fn build_firefox_143_client() -> Result<Client, GrokError> {
Ok(Client::builder()
.emulation(Emulation::Firefox143) // NB: THIS IS IDENTICAL IN FINGERPRINT TO REAL FIREFOX !!!
.connection_verbose(true)
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|err| GrokClientError::WreqClientError(err))?)
}
fn build_raw_firefox_client_with_proxy() -> Result<Client, GrokError> {
let proxy = Proxy::https("http://127.0.0.1:8080")
.map_err(|err| GrokClientError::WreqClientError(err))?;
Ok(Client::builder()
.emulation(Emulation::Firefox143)
.default_headers(HeaderMap::new())
.proxy(proxy)
.cert_verification(false)
.connection_verbose(true)
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|err| GrokClientError::WreqClientError(err))?)
}
async fn get_with_client(client: &Client) -> Result<String, GrokError> {
let builder = client.get(FINGERPRINT_URL)
.header("Accept", "application/json");
let response = builder.send()
.await
.map_err(|err| GrokClientError::WreqClientError(err))?;
let body = response.text()
.await
.map_err(|err| GrokClientError::WreqClientError(err))?;
Ok(body)
}
#[cfg(test)]
mod tests {
use errors::AnyhowResult;
use super::*;
#[tokio::test]
#[ignore] // manually test
async fn test() -> AnyhowResult<()> {
let client = build_firefox_143_client()?;
let body = get_with_client(&client).await?;
println!("{}", body);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,106 +0,0 @@
use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use cloudflare_mitigation::headers::firefox_websocket_http_1_1_headers::get_firefox_websocket_http_1_1_headers;
use std::ops::{Deref, DerefMut};
use std::time::Duration;
use tokio::io::AsyncReadExt;
use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, CACHE_CONTROL, COOKIE, ORIGIN, PRAGMA, SEC_WEBSOCKET_EXTENSIONS, USER_AGENT};
use wreq::ws::WebSocket;
use wreq::Client;
use wreq_util::Emulation;
const WEBSOCKET_URL: &str = "wss://grok.com/ws/imagine/listen";
pub struct CreateListenWebsocketArgs<'a> {
pub cookies: &'a str,
}
pub async fn create_listen_websocket(args: CreateListenWebsocketArgs<'_>) -> Result<WebSocket, GrokError> {
let mut client_builder = Client::builder()
.emulation(Emulation::Firefox143)
.connection_verbose(true)
.connect_timeout(Duration::from_secs(30));
let client = client_builder
.build()
.map_err(|err| GrokClientError::WreqClientError(err))?;
// NB: We need to be careful with this because HTTP/1.1 WebSocket handshake is sensitive to header casing and order.
// Cloudflare doesn't like lower case header names, and we're sending headers in the right order just to be safe.
let builder = client.websocket(WEBSOCKET_URL)
.default_headers(false)
.orig_headers(get_firefox_websocket_http_1_1_headers())
.header(COOKIE, args.cookies)
.header(ORIGIN,"https://grok.com")
.header(USER_AGENT, FIREFOX_143_MAC_USER_AGENT)
.header(SEC_WEBSOCKET_EXTENSIONS, "permessage-deflate")
.header(ACCEPT, "*/*")
.header(ACCEPT_LANGUAGE, "en-US,en;q=0.5")
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "websocket")
.header("Sec-Fetch-Site", "same-origin")
.header(PRAGMA, "no-cache")
.header(CACHE_CONTROL, "no-cache");
let mut response = builder.send()
.await
.map_err(|err| GrokClientError::WreqClientError(err))?;
let status = response.status();
match status.as_u16() {
101 => {
// Successful WebSocket upgrade ... fallthrough.
}
401 => {
// 401 is a likely authentication error with Grok itself.
// Unfortunately we can't read the response body text because wreq wants to consume the Response to get it, but the
// WebsocketResponse type wraps and doesn't let us move it. It's wreq's fault.
return Err(GrokError::ApiGeneric(GrokGenericApiError::LikelyWebsocketAuthentication401));
}
403 => {
// 403 is likely a cloudflare error
// Unfortunately we can't read the response body text because wreq wants to consume the Response to get it, but the
// WebsocketResponse type wraps and doesn't let us move it. It's wreq's fault.
return Err(GrokError::ApiGeneric(GrokGenericApiError::LikelyWebsocketCloudflare403));
}
_ => {
return Err(GrokError::ApiGeneric(GrokGenericApiError::UnexpectedWebsocketUpgradeStatusCode(status)));
},
}
let websocket = response.into_websocket()
.await
.map_err(|err| GrokGenericApiError::WreqWebsocketUpgradeError(err))?;
Ok(websocket)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // manually test
async fn create() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
//let cookies = SESSION_COOKIES_WITHOUT_CF_CLEARANCE;
let cookies = get_test_cookies()?;
let args = CreateListenWebsocketArgs {
cookies: &cookies,
};
let result = create_listen_websocket(args).await;
if let Err(err) = result {
println!("Error: {:?}", err);
}
log::logger().flush();
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,108 +0,0 @@
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage;
use futures::stream::FusedStream;
use futures::{Stream, TryStreamExt};
use log::{info, warn};
use serde::Serialize;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use wreq::ws::message::Message;
use wreq::ws::WebSocket;
pub struct GrokWebsocket {
pub(crate) websocket: WebSocket,
}
impl GrokWebsocket {
pub fn new(websocket: WebSocket) -> Self {
Self {
websocket,
}
}
pub async fn send(&mut self, message: String) -> Result<(), GrokError> {
let message = Message::text(message);
self.websocket.send(message)
.await
.map_err(|err| {
GrokClientError::WreqClientError(err)
})?;
Ok(())
}
pub async fn send_serializable<T: Serialize>(&mut self, message: T) -> Result<(), GrokError> {
let message_json = serde_json::to_string(&message)
.map_err(|err| {
warn!("Failed to serialize prompt websocket message: {}", err);
GrokClientError::WebsocketRequestSerializationError(err)
})?;
self.send(message_json).await
}
pub async fn get_response_with_timeout(&mut self, duration: Duration) -> Result<Option<WebsocketServerMessage>, GrokError> {
let result = tokio::time::timeout(duration, self.websocket.try_next()).await;
match result {
Err(elapsed) => {
info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed);
Ok(None) // Timeout elapsed.
}
Ok(inner) => {
let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?;
match maybe_message {
None => Ok(None),
Some(Message::Text(text)) => {
let maybe_message = WebsocketServerMessage::from_json_str(&text)?;
Ok(Some(maybe_message))
},
Some(_) => {
warn!("Received non-text websocket message.");
Ok(None)
},
}
}
}
}
pub async fn try_next_timeout(&mut self, duration: Duration) -> Result<Option<Message>, GrokClientError> {
let result = tokio::time::timeout(duration, self.websocket.try_next()).await;
match result {
Err(elapsed) => {
info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed);
Ok(None) // Timeout elapsed.
}
Ok(inner) => {
let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?;
Ok(maybe_message)
}
}
}
// pub async fn is_terminated(&self) -> Result<bool, GrokClientError> {
// match self.websocket.read() {
// Err(_) => Err(GrokClientError::WebsocketLockError.into()),
// Ok(websocket) => Ok(websocket.is_terminated()),
// }
// }
// /// NB: This will block forever if there is no item.
// pub async fn try_next(&self) -> Result<Option<Message>, GrokClientError> {
// match self.websocket.write() {
// Err(_) => Err(GrokClientError::WebsocketLockError),
// Ok(mut websocket) => websocket
// .try_next()
// .await
// .map_err(|err| GrokClientError::WebsocketReadError(err)),
// }
// }
// pub fn size_hint(&self) -> Result<(usize, Option<usize>), GrokClientError> {
// match self.websocket.read() {
// Err(_) => Err(GrokClientError::WebsocketLockError),
// Ok(websocket) => Ok(websocket.size_hint())
// }
// }
}
@@ -1,124 +0,0 @@
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage;
use futures::stream::FusedStream;
use futures::{Stream, TryStreamExt};
use log::{info, warn};
use serde::Serialize;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use wreq::ws::message::Message;
use wreq::ws::WebSocket;
#[derive(Clone)]
pub struct GrokWrappedWebsocket {
pub(crate) websocket: Arc<RwLock<WebSocket>>,
}
impl GrokWrappedWebsocket {
pub fn new(websocket: WebSocket) -> Self {
Self {
websocket: Arc::new(RwLock::new(websocket)),
}
}
pub async fn send(&self, message: String) -> Result<(), GrokError> {
match self.websocket.write() {
Err(err) => Err(GrokClientError::WebsocketLockError.into()),
Ok(mut websocket) => {
let message = Message::text(message);
websocket.send(message)
.await
.map_err(|err| {
GrokClientError::WreqClientError(err)
})?;
Ok(())
},
}
}
pub async fn send_serializable<T: Serialize>(&self, message: T) -> Result<(), GrokError> {
let message_json = serde_json::to_string(&message)
.map_err(|err| {
warn!("Failed to serialize prompt websocket message: {}", err);
GrokClientError::WebsocketRequestSerializationError(err)
})?;
self.send(message_json).await
}
pub async fn get_response_with_timeout(&self, duration: Duration) -> Result<Option<WebsocketServerMessage>, GrokError> {
match self.websocket.write() {
Err(_) => Err(GrokClientError::WebsocketLockError.into()),
Ok(mut websocket) => {
let result = tokio::time::timeout(duration, websocket.try_next()).await;
match result {
Err(elapsed) => {
info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed);
Ok(None) // Timeout elapsed.
}
Ok(inner) => {
let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?;
match maybe_message {
None => Ok(None),
Some(Message::Text(text)) => {
let maybe_message = WebsocketServerMessage::from_json_str(&text)?;
Ok(Some(maybe_message))
},
Some(_) => {
warn!("Received non-text websocket message.");
Ok(None)
},
}
}
}
},
}
}
pub async fn try_next_timeout(&self, duration: Duration) -> Result<Option<Message>, GrokClientError> {
match self.websocket.write() {
Err(_) => Err(GrokClientError::WebsocketLockError),
Ok(mut websocket) => {
let result = tokio::time::timeout(duration, websocket.try_next()).await;
match result {
Err(elapsed) => {
info!("Websocket try_next() elapsed without receiving a message: {:?}", elapsed);
Ok(None) // Timeout elapsed.
}
Ok(inner) => {
let maybe_message = inner.map_err(GrokClientError::WebsocketReadError)?;
Ok(maybe_message)
}
}
},
}
}
// pub async fn is_terminated(&self) -> Result<bool, GrokClientError> {
// match self.websocket.read() {
// Err(_) => Err(GrokClientError::WebsocketLockError.into()),
// Ok(websocket) => Ok(websocket.is_terminated()),
// }
// }
// /// NB: This will block forever if there is no item.
// pub async fn try_next(&self) -> Result<Option<Message>, GrokClientError> {
// match self.websocket.write() {
// Err(_) => Err(GrokClientError::WebsocketLockError),
// Ok(mut websocket) => websocket
// .try_next()
// .await
// .map_err(|err| GrokClientError::WebsocketReadError(err)),
// }
// }
// pub fn size_hint(&self) -> Result<(usize, Option<usize>), GrokClientError> {
// match self.websocket.read() {
// Err(_) => Err(GrokClientError::WebsocketLockError),
// Ok(websocket) => Ok(websocket.size_hint())
// }
// }
}
@@ -1,176 +0,0 @@
use crate::datatypes::api::request_id::RequestId;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::requests::image_websocket::grok_websocket::GrokWebsocket;
use crate::requests::image_websocket::grok_wrapped_websocket::GrokWrappedWebsocket;
use crate::requests::image_websocket::messages::message_image_data::MessageImageData;
use crate::utils::scrub_blobs_for_debug_logging::scrub_blobs_for_debug_logging;
use log::info;
use std::time::{Duration, Instant};
use wreq::ws::message::Message;
// Number of images we get per prompt by default
const DEFAULT_IMAGE_COUNT: usize = 6;
pub struct ListenForWebsocketImagesArgs<'a> {
pub websocket: &'a mut GrokWebsocket,
pub timeout: Duration,
}
#[derive(Clone, Debug)]
pub struct ImageResults {
pub images: Vec<ImageData>,
}
#[derive(Clone, Debug)]
pub struct ImageData {
/// The "task" that generated the image
/// Multiple images have the same "task"
pub request_id: RequestId,
/// Url where we can download the image.
pub url: String,
/// The user-input prompt
pub user_prompt: String,
/// An X.ai-enriched prompt
pub enriched_prompt: String,
}
pub async fn listen_for_websocket_images(args: ListenForWebsocketImagesArgs<'_>) -> Result<ImageResults, GrokError> {
let start = Instant::now();
let end_at = start.checked_add(args.timeout);
let end_at = match end_at {
Some(end_at) => end_at,
None => return Err(GrokClientError::TimeoutMathBroken.into()),
};
let mut images = Vec::new();
loop {
if end_at < Instant::now() {
break;
}
let maybe_message = args.websocket.try_next_timeout(Duration::from_millis(500)).await?;
let maybe_message = match maybe_message {
None => None,
Some(Message::Text(text)) => Some(text.to_string()),
Some(_) => {
// Got a ping, pong, binary payload, etc.
None
},
};
let message = match maybe_message {
Some(message) => message,
None => continue,
};
let maybe_image_data = serde_json::from_str::<MessageImageData>(&message);
let image_data = match maybe_image_data {
Ok(image_data) => image_data,
Err(_err) => {
let message = scrub_blobs_for_debug_logging(&message);
println!("Unknown message: {}", message);
// NB: Might not have been a parse error, since we're parsing irrelevant message payloads too.
// This is slightly dangerous to do this way as we might mask true parsing errors with image payloads.
continue;
}
};
match image_data.percentage_complete {
None => continue,
Some(percent) => {
if percent < 100.0 {
continue;
}
}
}
images.push(ImageData {
request_id: RequestId(image_data.request_id),
url : image_data.url,
enriched_prompt: image_data.full_prompt,
user_prompt: image_data.prompt,
});
if images.len() >= DEFAULT_IMAGE_COUNT {
info!("{} images generated; we're done polling.", images.len());
break;
}
}
Ok(ImageResults {
images,
})
}
#[cfg(test)]
mod tests {
use crate::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs};
use crate::requests::image_websocket::grok_websocket::GrokWebsocket;
use crate::requests::image_websocket::listen_for_websocket_images::{listen_for_websocket_images, ListenForWebsocketImagesArgs};
use crate::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio;
use crate::requests::image_websocket::prompt_websocket_image::{prompt_websocket_image, PromptWebsocketImageArgs};
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
use std::io::Write;
use std::time::Duration;
#[tokio::test]
#[ignore] // manually test
async fn prompt() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let prompt = "A tornado hitting the stadium";
let cookies = get_test_cookies()?;
let websocket = create_listen_websocket(CreateListenWebsocketArgs {
cookies: &cookies,
}).await?;
//let websocket = GrokWrappedWebsocket::new(websocket);
let mut websocket = GrokWebsocket::new(websocket);
println!("Sending...");
std::io::stdout().flush()?;
let _result = prompt_websocket_image(PromptWebsocketImageArgs {
websocket: &mut websocket,
prompt,
aspect_ratio: ClientMessageAspectRatio::WideThreeByTwo,
}).await?;
println!("Reading...");
std::io::stdout().flush()?;
println!("Polling.");
let images = listen_for_websocket_images(ListenForWebsocketImagesArgs {
websocket: &mut websocket,
timeout: Duration::from_millis(10_000),
}).await?;
println!("Done polling. Images: {}", images.images.len());
for image in images.images {
println!("Image: {:?}", image);
}
log::logger().flush();
assert_eq!(1,2);
Ok(())
}
}
@@ -1,127 +0,0 @@
use crate::datatypes::api::request_id::RequestId;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::requests::image_websocket::grok_websocket::GrokWebsocket;
use crate::requests::image_websocket::messages::message_request_id::MessageRequestId;
use std::time::{Duration, Instant};
use wreq::ws::message::Message;
pub struct ListenForWebsocketRequestIdArgs<'a> {
pub websocket: &'a mut GrokWebsocket,
pub timeout: Duration,
}
#[derive(Clone, Debug)]
pub struct RequestIdResult {
pub request_id: Option<RequestId>,
}
pub async fn listen_for_websocket_request_id(args: ListenForWebsocketRequestIdArgs<'_>) -> Result<RequestIdResult, GrokError> {
let start = Instant::now();
let end_at = start.checked_add(args.timeout);
let end_at = match end_at {
Some(end_at) => end_at,
None => return Err(GrokClientError::TimeoutMathBroken.into()),
};
let mut request_id = None;
loop {
if end_at < Instant::now() {
break;
}
let maybe_message = args.websocket.try_next_timeout(Duration::from_millis(500)).await?;
let maybe_message = match maybe_message {
None => None,
Some(Message::Text(text)) => Some(text.to_string()),
Some(_) => {
// Got a ping, pong, binary payload, etc.
None
},
};
let message = match maybe_message {
Some(message) => message,
None => continue,
};
let maybe_parsed = serde_json::from_str::<MessageRequestId>(&message);
let parsed = match maybe_parsed {
Ok(parsed) => parsed,
Err(_err) => {
// NB: Might not have been a parse error, since we're parsing irrelevant message payloads too.
// This is slightly dangerous to do this way as we might mask true parsing errors with image payloads.
continue;
}
};
request_id = Some(RequestId(parsed.request_id));
break;
}
Ok(RequestIdResult {
request_id,
})
}
#[cfg(test)]
mod tests {
use crate::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs};
use crate::requests::image_websocket::grok_websocket::GrokWebsocket;
use crate::requests::image_websocket::listen_for_websocket_request_id::{listen_for_websocket_request_id, ListenForWebsocketRequestIdArgs};
use crate::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio;
use crate::requests::image_websocket::prompt_websocket_image::{prompt_websocket_image, PromptWebsocketImageArgs};
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
use std::io::Write;
use std::time::Duration;
#[tokio::test]
#[ignore] // manually test
async fn prompt() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Info);
let prompt = "A tiny tornado on a desk";
let cookies = get_test_cookies()?;
let mut websocket = create_listen_websocket(CreateListenWebsocketArgs {
cookies: &cookies,
}).await?;
//let websocket = GrokWrappedWebsocket::new(websocket);
let mut websocket = GrokWebsocket::new(websocket);
println!("Sending...");
std::io::stdout().flush()?;
let _result = prompt_websocket_image(PromptWebsocketImageArgs {
websocket: &mut websocket,
prompt,
aspect_ratio: ClientMessageAspectRatio::Square,
}).await?;
println!("Reading...");
std::io::stdout().flush()?;
println!("Polling.");
let request_data = listen_for_websocket_request_id(ListenForWebsocketRequestIdArgs {
websocket: &mut websocket,
timeout: Duration::from_millis(10_000),
}).await?;
println!("Done polling. Request Data: {:?}", request_data);
log::logger().flush();
assert_eq!(1,2);
Ok(())
}
}
@@ -1,63 +0,0 @@
use serde::Deserialize;
/// Images with a binary blob and URLs.
/// We may receive several of these for a single prompt.
#[derive(Deserialize, Clone, Debug)]
pub struct MessageImageData {
/// UUID.
/// Probably an identifier for the original request/prompt.
pub request_id: String, // Option<String>,
/// UUID.
/// This turns out to be the file ID in the image URL (and `job_id`).
pub id: Option<String>,
/// UUID.
/// This turns out to be the file ID in the image URL (and `id`).
pub job_id: Option<String>,
/// Eg. null, "0", "50", "100"
pub percentage_complete: Option<f32>,
/// The user's prompt
pub prompt: String, //pub prompt: Option<String>,
/// The X.ai enriched prompt
pub full_prompt: String, //pub full_prompt: Option<String>,
/// URL to the image.
pub url: String, //pub url: Option<String>,
// We don't need to decode - let's just download again
// /// Base64 encoded image blob.
// pub blob: Option<String>,
// /// NSFW flag
// pub r_rated: Option<bool>,
// /// Name of the model used to generate the image
// /// eg. "imagine_h_1", "imagine_x_1"
// pub model_name: Option<String>,
}
/*
Received websocket message: {
"type": "image",
"blob": "...",
"job_id": "260270db-2515-470b-a730-1fe87e11d21c",
"grid_index": 0,
"is_alteration": false,
"order": 0,
"section_id": 0,
"request_id": "57b94c87-ae13-4ade-8fc0-ebd39fb511ec",
"url": "https://imagine-public.x.ai/imagine-public/images/260270db-2515-470b-a730-1fe87e11d21c.png",
"id": "260270db-2515-470b-a730-1fe87e11d21c",
"prompt": "a dog riding a motorcycle",
"full_prompt": "A dog sits in a motorcycle sidecar on a paved road with a natural background.",
"percentage_complete": 100.0,
"model_name": "imagine_x_1",
"suggested_moderation_rule": "MODERATION_RULE_DEFAULT",
"r_rated":false
}
*/
@@ -1,11 +0,0 @@
use serde::Deserialize;
/// This could intercept any message with a "request_id"
/// We're going to try to use these to end the image prompting early.
/// If we're not careful, we might accidentally ingest completed image messages this way.
#[derive(Deserialize, Clone, Debug)]
pub struct MessageRequestId {
/// UUID.
/// Probably an identifier for the original request/prompt.
pub request_id: String, // Option<String>,
}
@@ -1,4 +0,0 @@
pub mod message_image_data;
pub mod message_request_id;
pub mod websocket_client_message;
pub mod websocket_server_message;
@@ -1,90 +0,0 @@
use chrono::Utc;
use serde::Serialize;
use uuid::Uuid;
#[derive(Serialize)]
pub struct WebsocketClientMessage {
/// Type of message
/// eg. "conversation.item.create"
pub r#type: String,
/// Unix timestamp
pub timestamp: u64,
pub item: ClientMessageItem,
}
#[derive(Serialize)]
pub struct ClientMessageItem {
/// Type of item
/// eg. "message"
pub r#type: String,
pub content: Vec<ClientMessageItemContent>,
}
#[derive(Serialize)]
pub struct ClientMessageItemContent {
/// UUID
#[serde(rename="requestId")]
pub request_id: String,
/// Type of item content
/// eg. "input_text"
pub r#type: String,
/// The prompt for the request
pub text: String,
pub properties: ClientMessageItemContentProperties,
}
#[derive(Serialize)]
pub struct ClientMessageItemContentProperties {
pub section_count: usize,
pub is_kids_mode: bool,
pub enable_nsfw: bool,
pub skip_upsampler: bool,
pub is_initial: bool,
pub aspect_ratio: ClientMessageAspectRatio,
}
#[derive(Serialize, Clone, Copy, Debug)]
pub enum ClientMessageAspectRatio {
#[serde(rename = "2:3")]
TallTwoByThree,
#[serde(rename = "3:2")]
WideThreeByTwo,
#[serde(rename = "1:1")]
Square,
}
impl WebsocketClientMessage {
/// Create a new image prompt websocket client message
pub fn new_image_prompt(prompt: &str, aspect_ratio: ClientMessageAspectRatio) -> Self {
Self {
r#type: "conversation.item.create".to_string(),
timestamp: Utc::now().timestamp_millis() as u64,
item: ClientMessageItem {
r#type: "message".to_string(),
content: vec![
ClientMessageItemContent {
request_id: Uuid::new_v4().to_string(),
r#type: "input_text".to_string(),
text: prompt.to_string(),
properties: ClientMessageItemContentProperties {
section_count: 0,
is_kids_mode: false,
enable_nsfw: true,
skip_upsampler: false,
is_initial: false,
aspect_ratio,
},
}
],
},
}
}
}
@@ -1,205 +0,0 @@
use crate::error::grok_generic_api_error::GrokGenericApiError;
use serde::Deserialize;
#[derive(Deserialize, Clone, Debug)]
#[serde(tag = "type")]
pub enum WebsocketServerMessage {
/// Json with root `type = "image"`
#[serde(rename = "image")]
Image(ImageDataMessage),
/// Json with root `type = "json"`
#[serde(rename = "json")]
Json(JsonDataMessage),
/// Captures any other JSON messages.
#[serde(untagged)]
Unknown(serde_json::Value),
//Unknown(String),
}
/// Images with a binary blob and URLs.
/// We may receive several of these for a single prompt.
#[derive(Deserialize, Clone, Debug)]
pub struct ImageDataMessage {
/// UUID.
pub id: Option<String>,
/// UUID.
pub job_id: Option<String>,
/// UUID.
pub request_id: Option<String>,
/// Eg. "0", "50", "100"
pub percentage_complete: Option<f32>,
pub prompt: Option<String>,
/// The enriched prompt
pub full_prompt: Option<String>,
/// Base64 encoded image blob.
pub blob: Option<String>,
/// URL to the image.
pub url: Option<String>,
/// NSFW flag
pub r_rated: Option<bool>,
/// Name of the model used to generate the image
/// eg. "imagine_h_1"
pub model_name: Option<String>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct JsonDataMessage {
/// UUID.
pub job_id: Option<String>,
/// UUID.
pub request_id: Option<String>,
/// Eg. "0", "50", "100"
pub percentage_complete: Option<f32>,
pub prompt: Option<String>,
pub full_prompt: Option<String>,
/// NSFW flag
pub r_rated: Option<bool>,
/// UUID.
/// NB: This is observed to be nullable.
pub image_id: Option<String>,
}
impl WebsocketServerMessage {
pub fn from_json_str(json_str: &str) -> Result<Self, GrokGenericApiError> {
Ok(serde_json::from_str(json_str)
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, json_str.to_string()))?)
}
}
#[cfg(test)]
mod tests {
use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage;
fn json_path(file_name: &str) -> String {
// NB: Cargo runs tests with the crate root as the working directory.
format!("test_data/websocket_messages/{}", file_name)
}
#[test]
fn test_image_complete() -> anyhow::Result<()> {
let path = json_path("create_image_complete_response.json");
let data = std::fs::read_to_string(path)?;
//let message: WebsocketServerMessage = serde_json::from_str(&data)?;
let message = WebsocketServerMessage::from_json_str(&data)?;
match message {
WebsocketServerMessage::Image(image_data) => {
assert_eq!(image_data.percentage_complete, Some(50.0));
assert!(image_data.url.is_some());
},
_ => panic!("Expected ImageData message"),
}
Ok(())
}
#[test]
fn test_image_status_complete() -> anyhow::Result<()> {
let path = json_path("create_image_complete_status_notification.json");
let data = std::fs::read_to_string(path)?;
//let message: WebsocketServerMessage = serde_json::from_str(&data)?;
let message = WebsocketServerMessage::from_json_str(&data)?;
match message {
WebsocketServerMessage::Json(image_data) => {
assert_eq!(image_data.percentage_complete, Some(100.0));
},
_ => panic!("Expected JsonData message"),
}
Ok(())
}
#[test]
fn test_in_progress() -> anyhow::Result<()> {
let path = json_path("create_image_in_progress_response.json");
let data = std::fs::read_to_string(path)?;
//let message: WebsocketServerMessage = serde_json::from_str(&data)?;
let message = WebsocketServerMessage::from_json_str(&data)?;
match message {
WebsocketServerMessage::Json(image_data) => {
assert_eq!(image_data.percentage_complete, Some(0.0));
},
_ => panic!("Expected JsonData message"),
}
Ok(())
}
#[test]
fn test_wire() -> anyhow::Result<()> {
let path = json_path("wire.json");
let data = std::fs::read_to_string(path)?;
//let message: WebsocketServerMessage = serde_json::from_str(&data)?;
let message = WebsocketServerMessage::from_json_str(&data)?;
match message {
WebsocketServerMessage::Json(_value)=> {},
_ => panic!("Expected Json message"),
}
Ok(())
}
#[test]
fn test_wire_formatted() -> anyhow::Result<()> {
let path = json_path("wire_formatted.json");
let data = std::fs::read_to_string(path)?;
//let message: WebsocketServerMessage = serde_json::from_str(&data)?;
let message = WebsocketServerMessage::from_json_str(&data)?;
match message {
WebsocketServerMessage::Json(_value)=> {},
_ => panic!("Expected Json message"),
}
Ok(())
}
#[test]
fn test_empty() -> anyhow::Result<()> {
let path = json_path("empty.json");
let data = std::fs::read_to_string(path)?;
//let message: WebsocketServerMessage = serde_json::from_str(&data)?;
let message = WebsocketServerMessage::from_json_str(&data)?;
match message {
WebsocketServerMessage::Unknown(_value)=> {},
_ => panic!("Expected Unknown message"),
}
Ok(())
}
#[test]
fn test_other_type() -> anyhow::Result<()> {
let path = json_path("other_type.json");
let data = std::fs::read_to_string(path)?;
///let message: WebsocketServerMessage = serde_json::from_str(&data)?;
let message = WebsocketServerMessage::from_json_str(&data)?;
match message {
WebsocketServerMessage::Unknown(_value)=> {},
_ => panic!("Expected Unknown message"),
}
Ok(())
}
}
@@ -1,7 +0,0 @@
pub mod grok_wrapped_websocket;
pub mod create_listen_websocket;
pub mod listen_for_websocket_images;
pub mod listen_for_websocket_request_id;
pub mod messages;
pub mod prompt_websocket_image;
pub mod grok_websocket;
@@ -1,171 +0,0 @@
use crate::error::grok_error::GrokError;
use crate::requests::image_websocket::grok_websocket::GrokWebsocket;
use crate::requests::image_websocket::messages::websocket_client_message::{ClientMessageAspectRatio, WebsocketClientMessage};
pub struct PromptWebsocketImageArgs<'a> {
pub websocket: &'a mut GrokWebsocket,
pub prompt: &'a str,
pub aspect_ratio: ClientMessageAspectRatio,
}
pub async fn prompt_websocket_image(args: PromptWebsocketImageArgs<'_>) -> Result<(), GrokError> {
let message = WebsocketClientMessage::new_image_prompt(args.prompt, args.aspect_ratio);
args.websocket.send_serializable(&message).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::requests::image_websocket::create_listen_websocket::{create_listen_websocket, CreateListenWebsocketArgs};
use crate::requests::image_websocket::grok_websocket::GrokWebsocket;
use crate::requests::image_websocket::grok_wrapped_websocket::GrokWrappedWebsocket;
use crate::requests::image_websocket::messages::websocket_client_message::ClientMessageAspectRatio;
use crate::requests::image_websocket::messages::websocket_server_message::WebsocketServerMessage;
use crate::requests::image_websocket::prompt_websocket_image::{prompt_websocket_image, PromptWebsocketImageArgs};
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
use log::warn;
use std::io::Write;
use std::time::Duration;
use wreq::ws::message::Message;
#[tokio::test]
#[ignore] // manually test
async fn prompt() -> AnyhowResult<()> {
//setup_test_logging(LevelFilter::Trace);
let cookies = get_test_cookies()?;
let websocket = create_listen_websocket(CreateListenWebsocketArgs {
cookies: &cookies,
}).await?;
//let websocket = GrokWrappedWebsocket::new(websocket);
let mut websocket = GrokWebsocket::new(websocket);
println!("Sending...");
std::io::stdout().flush()?;
let result = prompt_websocket_image(PromptWebsocketImageArgs {
websocket: &mut websocket,
prompt: "a dog riding a motorcycle",
aspect_ratio: ClientMessageAspectRatio::WideThreeByTwo,
}).await?;
println!("Reading...");
std::io::stdout().flush()?;
let mut count = 0;
println!("Polling.");
//while let Some(message) = websocket.try_next().await? {
// println!("[{count}] Received websocket message: {:?}", message);
// count = count + 1;
// tokio::time::sleep(Duration::from_millis(1000)).await;
// if count > 1000 {
// break;
// }
//}
//loop {
// count = count + 1;
// let bounds = websocket.size_hint()?;
// println!("Websocket size hint: {:?}", bounds);
// tokio::time::sleep(Duration::from_millis(1000)).await;
// if count > 30 {
// break;
// }
//}
//loop {
// let maybe_message =
// websocket.try_next_timeout(Duration::from_millis(1000)).await?;
// match maybe_message {
// None => {
// println!("No message received within timeout.");
// count = count + 1;
// }
// Some(message) => {
// println!("[{count}] Received websocket message!");//: {:?}", message);
// count = 0;
// }
// }
// if count > 5 {
// println!("No messages after 5 seconds");
// break;
// }
//}
loop {
//let maybe_message =
// websocket.get_response_with_timeout(Duration::from_millis(1000)).await?;
let maybe_message =
websocket.try_next_timeout(Duration::from_millis(1000)).await?;
let mut maybe_raw_text : Option<String> = None;
let maybe_message = match maybe_message {
None => None,
Some(Message::Text(text)) => {
let maybe_message = WebsocketServerMessage::from_json_str(text.as_str())?;
let mut text = text.as_str().to_string();
text.truncate(100);
//println!("Received message: {}", text);
maybe_raw_text = Some(text);
Some(maybe_message)
},
Some(_) => {
warn!("Received non-text websocket message.");
None
},
};
match maybe_message {
None => {
println!("No message received within timeout.");
count = count + 1;
}
Some(message) => {
match message {
WebsocketServerMessage::Image(image) => {
println!("IMAGE: {:?}", image.percentage_complete);
}
WebsocketServerMessage::Json(json) => {
println!("JSON : {:?}", json.percentage_complete);
}
WebsocketServerMessage::Unknown(unknown) => {
//let typ = unknown.get("type");
let unknown_string = unknown.as_str();
println!("[UNKNOWN] websocket message: {:?}", maybe_raw_text);
}
}
count = 0;
}
}
// TODO: Make sure we capture all the events for images
// TODO: Explore the other APIs
// TODO understand the IDs
// TODO: Video websocket APIs
// TODO: Upload APIs
if count > 5 {
println!("No messages after 5 seconds");
break;
}
}
println!("Done polling.");
log::logger().flush();
assert_eq!(1,2);
Ok(())
}
}
@@ -1,102 +0,0 @@
use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use log::info;
use std::ops::Deref;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, TE, UPGRADE, USER_AGENT};
use wreq::ws::message::Message;
use wreq::Client;
use wreq_util::Emulation;
const INDEX_URL: &str = "https://grok.com";
pub struct GetIndexPageArgs<'a> {
pub cookie: &'a str,
}
pub struct IndexPage {
pub body: String,
}
pub async fn get_index(args: GetIndexPageArgs<'_>) -> Result<IndexPage, GrokError> {
info!("Building client...");
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| GrokClientError::WreqClientError(err))?;
info!("Configuring client...");
let builder = client.get(INDEX_URL)
.header("User-Agent", FIREFOX_143_MAC_USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Accept-Language", "en-US,en;q=0.5")
.header("Connection", "keep-alive")
.header("Sec-Fetch-Dest", "document")
.header("Sec-Fetch-Mode", "navigate")
.header("Sec-Fetch-Site", "none")
.header("Sec-Fetch-User", "?1")
.header("Sec-GPC", "1")
.header("priority", "u=0, i")
.header("Pragma", "no-cache")
.header("Cache-Control", "no-cache")
.header("Cookie", args.cookie.to_string())
.header("TE", "trailers");
info!("Sending request for index page...");
let response = builder.send()
.await
.map_err(|err| GrokClientError::WreqClientError(err))?;
let status = response.status();
println!("Status: {}", status);
//let body = response.text()
// .await
// .map_err(|err| GrokGenericApiError::WreqError(err))?;
// stblid=08552693-0377-49d3-b17f-8e4c68b153ec
for cookie in response.cookies() {
println!("Cookie: {}={}", cookie.name(), cookie.value());
}
for (name, value) in response.headers() {
println!("Header: {}: {}", name.as_str(), value.to_str().unwrap());
}
let body = response.text()
.await
.map_err(|err| GrokClientError::WreqClientError(err))?;
Ok(IndexPage {
body,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // manually test
async fn test() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let cookie = get_test_cookies()?;
let args = GetIndexPageArgs {
cookie: &cookie,
};
let result = get_index(args).await?;
println!("Body:\n\n");
println!("{}", result.body);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,88 +0,0 @@
use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::requests::index_page::parsers::index::parse_index_script_list::parse_index_svg_paths;
use crate::requests::index_page::requests::get_index_page_scripts_with_client::{get_index_page_scripts_with_client, GetIndexPageScriptsArgs};
use crate::requests::index_page::requests::get_index_page_with_client::{get_index_page_with_client, GetIndexPageWithClientArgs};
use crate::utils::create_firefox_client::create_firefox_client;
use log::info;
use std::collections::HashMap;
use wreq::Client;
use wreq_util::Emulation;
const INDEX_URL: &str = "https://grok.com";
pub struct GetIndexPageAndScriptsArgs<'a> {
pub cookie: &'a str,
}
pub struct IndexPageAndScripts {
/// Index.html
pub index_body_html: String,
/// Script path -> Javascript payload.
pub scripts: HashMap<String, String>,
// The client that we used.
pub client: Client,
}
pub async fn get_index_page_and_scripts(args: GetIndexPageAndScriptsArgs<'_>) -> Result<IndexPageAndScripts, GrokError> {
info!("Building client...");
let client = create_firefox_client()?;
info!("Fetching index page...");
let index = get_index_page_with_client(GetIndexPageWithClientArgs {
client: &client,
cookie: args.cookie,
}).await?;
let scripts = parse_index_svg_paths(&index.body);
info!("Fetching {} scripts...", scripts.len());
let scripts = get_index_page_scripts_with_client(GetIndexPageScriptsArgs {
client: &client,
cookie: &args.cookie,
scripts: &scripts,
}).await?;
Ok(IndexPageAndScripts {
index_body_html: index.body,
scripts,
client,
})
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page_and_scripts::{get_index_page_and_scripts, GetIndexPageAndScriptsArgs};
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // manually test
async fn test() -> AnyhowResult<()> {
//setup_test_logging(LevelFilter::Trace);
let cookie = get_test_cookies()?;
let result = get_index_page_and_scripts(GetIndexPageAndScriptsArgs {
cookie: &cookie,
}).await?;
let mut truncated_body = result.index_body_html.clone();
truncated_body.truncate(500);
println!("Body:\n\n");
println!("{}", truncated_body);
for (k, v) in result.scripts.iter() {
let mut body = v.to_string();
body.truncate(100);
println!("{}: {}\n\n", k, body);
}
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,87 +0,0 @@
use crate::datatypes::api::xsid_numbers::XsidNumbers;
use crate::error::grok_error::GrokError;
use crate::requests::index_page::parsers::index::parse_index_on_demand_script::parse_index_on_demand_script;
use crate::requests::index_page::parsers::script::parse_xsid_script_numbers::parse_xsid_script_numbers;
use crate::requests::index_page::requests::get_index_page_script_with_client::{get_index_page_script_with_client, GetIndexPageScriptArgs};
use crate::requests::index_page::utils::convert_verification_token_to_loading_anim::LoadingAnim;
use wreq::Client;
// self.svg_data, self.numbers = Parser.parse_values(c_request.text, self.anim, self.xsid_script)
pub struct GetNumbersArgs<'a> {
pub client: &'a Client,
pub html: &'a str,
pub cookie: &'a str,
pub loading_anim: &'a LoadingAnim,
pub xsid_script_id: &'a str,
}
pub async fn get_numbers(args: GetNumbersArgs<'_>) -> Result<XsidNumbers, GrokError> {
let script_link = match args.xsid_script_id {
"ondemand.s" => {
// TODO: Not sure this is relevant anymore. Not a full or correct implementation of this branch.
let on_demand = parse_index_on_demand_script(&args.html);
let on_demand = on_demand.unwrap_or_else(|| "".to_string());
format!("https://abs.twimg.com/responsive-web/client-web/ondemand.s.{}a.js", on_demand)
},
_ => format!("https://grok.com/_next/{}", args.xsid_script_id),
};
let xsid_script_body = get_index_page_script_with_client(GetIndexPageScriptArgs {
client: &args.client,
cookie: args.cookie,
script_url: &script_link,
}).await?;
Ok(parse_xsid_script_numbers(&xsid_script_body))
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page_and_scripts::{get_index_page_and_scripts, GetIndexPageAndScriptsArgs};
use crate::requests::index_page::get_numbers::{get_numbers, GetNumbersArgs};
use crate::requests::index_page::parsers::index::parse_index_verification_token::parse_index_verification_token;
use crate::requests::index_page::parsers::script::parse_script_actions_and_xsid_script_path::parse_script_actions_and_xsid_script_path;
use crate::requests::index_page::utils::convert_verification_token_to_loading_anim::convert_verification_token_to_loading_anim;
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookie = get_test_cookies()?;
let page_and_scripts = get_index_page_and_scripts(GetIndexPageAndScriptsArgs {
cookie: &cookie,
}).await?;
let verification_token = parse_index_verification_token(&page_and_scripts.index_body_html)
.expect("expected verification token");
println!("Verification Token: {:?}", verification_token);
let loading_anim = convert_verification_token_to_loading_anim(&verification_token)?;
println!("Loading Animation: {:?}", loading_anim);
let actions_and_xsid_script = parse_script_actions_and_xsid_script_path(&page_and_scripts.scripts)?;
println!("Actions and XSID: {:?}", actions_and_xsid_script);
let result = get_numbers(GetNumbersArgs {
client: &page_and_scripts.client,
html: &page_and_scripts.index_body_html,
cookie: &cookie,
loading_anim: &loading_anim,
xsid_script_id: &actions_and_xsid_script.xsid_script_path,
}).await?;
println!("Final Numbers: {:?}", result);
println!("\n\nBody:\n\n");
println!("{}", page_and_scripts.index_body_html);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,92 +0,0 @@
use crate::error::grok_error::GrokError;
use crate::requests::index_page::parsers::index::parse_index_on_demand_script::parse_index_on_demand_script;
use crate::requests::index_page::requests::get_index_page_script_with_client::{get_index_page_script_with_client, GetIndexPageScriptArgs};
use wreq::Client;
// self.svg_data, self.numbers = Parser.parse_values(c_request.text, self.anim, self.xsid_script)
pub struct GetXsidScriptArgs<'a> {
pub client: &'a Client,
pub html: &'a str,
pub cookie: &'a str,
pub xsid_script_id: &'a str,
}
/// We pull numbers out of the xsid script
#[derive(Clone, Debug)]
pub struct XsidScript {
pub xsid_script_body: String,
}
pub async fn get_xsid_script(args: GetXsidScriptArgs<'_>) -> Result<XsidScript, GrokError> {
let script_link = match args.xsid_script_id {
"ondemand.s" => {
// TODO: Not sure this is relevant anymore. Not a full or correct implementation of this branch.
let on_demand = parse_index_on_demand_script(&args.html);
let on_demand = on_demand.unwrap_or_else(|| "".to_string());
format!("https://abs.twimg.com/responsive-web/client-web/ondemand.s.{}a.js", on_demand)
},
_ => format!("https://grok.com/_next/{}", args.xsid_script_id),
};
let xsid_script_body = get_index_page_script_with_client(GetIndexPageScriptArgs {
client: &args.client,
cookie: args.cookie,
script_url: &script_link,
}).await?;
Ok(XsidScript {
xsid_script_body,
})
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page_and_scripts::{get_index_page_and_scripts, GetIndexPageAndScriptsArgs};
use crate::requests::index_page::get_numbers::{get_numbers, GetNumbersArgs};
use crate::requests::index_page::parsers::index::parse_index_verification_token::parse_index_verification_token;
use crate::requests::index_page::parsers::script::parse_script_actions_and_xsid_script_path::parse_script_actions_and_xsid_script_path;
use crate::requests::index_page::utils::convert_verification_token_to_loading_anim::convert_verification_token_to_loading_anim;
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookie = get_test_cookies()?;
let page_and_scripts = get_index_page_and_scripts(GetIndexPageAndScriptsArgs {
cookie: &cookie,
}).await?;
let verification_token = parse_index_verification_token(&page_and_scripts.index_body_html)
.expect("expected verification token");
println!("Verification Token: {:?}", verification_token);
let loading_anim = convert_verification_token_to_loading_anim(&verification_token)?;
println!("Loading Animation: {:?}", loading_anim);
let actions_and_xsid_script = parse_script_actions_and_xsid_script_path(&page_and_scripts.scripts)?;
println!("Actions and XSID: {:?}", actions_and_xsid_script);
let result = get_numbers(GetNumbersArgs {
client: &page_and_scripts.client,
html: &page_and_scripts.index_body_html,
cookie: &cookie,
loading_anim: &loading_anim,
xsid_script_id: &actions_and_xsid_script.xsid_script_path,
}).await?;
println!("Final Numbers: {:?}", result);
println!("\n\nBody:\n\n");
println!("{}", page_and_scripts.index_body_html);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,8 +0,0 @@
pub (super) mod requests;
pub mod get_index_page;
pub mod get_index_page_and_scripts;
pub mod get_numbers;
pub mod get_xsid_script;
pub mod parsers;
pub mod signature;
pub mod utils;
@@ -1,8 +0,0 @@
pub mod parse_index_baggage;
pub mod parse_index_on_demand_script;
pub mod parse_index_script_list;
pub mod parse_index_sentry_trace;
pub mod parse_index_svg_paths;
pub mod parse_index_user_email;
pub mod parse_index_user_id;
pub mod parse_index_verification_token;
@@ -1,44 +0,0 @@
use once_cell::sync::Lazy;
use scraper::{Html, Selector};
use crate::datatypes::api::baggage::Baggage;
// <meta name="baggage" content="sentry-environment=production,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c,sentry-trace_id=fb5c42c8cff6fd39161dd245154ca599,sentry-org_id=4508179396558848,sentry-sampled=false,sentry-sample_rand=0.7208394686924251,sentry-sample_rate=0"/>
static BAGGAGE_SELECTOR : Lazy<Selector> = Lazy::new(|| {
Selector::parse("meta[name=baggage]")
.expect("HTML selector should parse")
});
pub fn parse_index_baggage(html: &str) -> Option<Baggage> {
let document = Html::parse_document(html);
let selected = document.select(&BAGGAGE_SELECTOR);
let mut values = selected
.filter_map(|node| node.attr("content"))
.map(|s| s.to_string())
.collect::<Vec<_>>();
values.pop().map(|b| Baggage(b))
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs};
use crate::requests::index_page::parsers::index::parse_index_baggage::parse_index_baggage;
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookie = get_test_cookies()?;
let index = get_index(GetIndexPageArgs {
cookie: &cookie,
}).await?;
let baggage = parse_index_baggage(&index.body);
println!("Baggage: {:?}", baggage);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,47 +0,0 @@
use once_cell::sync::Lazy;
use regex::Regex;
static ON_DEMAND_REGEX : Lazy<Regex> = Lazy::new(|| {
Regex::new(r#""ondemand.s":"([^"]*)""#)
.expect("Regex should parse")
});
pub fn parse_index_on_demand_script(html: &str) -> Option<String> {
let on_demand = scrape_on_demand_via_regex(html);
if let Some(on_demand) = on_demand {
return Some(on_demand);
}
None
}
fn scrape_on_demand_via_regex(html: &str) -> Option<String> {
let captures = ON_DEMAND_REGEX.captures(html)?;
let capture = captures.get(1)?;
Some(capture.as_str().to_string())
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs};
use crate::requests::index_page::parsers::index::parse_index_on_demand_script::parse_index_on_demand_script;
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookie = get_test_cookies()?;
let index = get_index(GetIndexPageArgs {
cookie: &cookie,
}).await?;
let result = parse_index_on_demand_script(&index.body);
println!("{:?}", result);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,48 +0,0 @@
use once_cell::sync::Lazy;
use scraper::{Html, Selector};
// scripts: list = [s['src'] for s in BeautifulSoup(load_site.text, 'html.parser').find_all('script', src=True) if s['src'].startswith('/_next/static/chunks/')]
static SCRIPT_SELECTOR: Lazy<Selector> = Lazy::new(|| {
Selector::parse("script[src]")
.expect("HTML selector should parse")
});
pub fn parse_index_svg_paths(html: &str) -> Vec<String> {
let document = Html::parse_document(html);
let selected = document.select(&SCRIPT_SELECTOR);
selected
.filter_map(|node| node.attr("src"))
.filter(|src| src.starts_with("/_next/static/chunks/"))
.map(|s| s.to_string())
.collect::<Vec<_>>()
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs};
use crate::requests::index_page::parsers::index::parse_index_script_list::parse_index_svg_paths;
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookie = get_test_cookies()?;
let index = get_index(GetIndexPageArgs {
cookie: &cookie,
}).await?;
let scripts = parse_index_svg_paths(&index.body);
println!("Script count: {:?}", scripts.len());
for script in scripts {
println!("{}", script);
}
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,85 +0,0 @@
use crate::datatypes::api::sentry_trace::SentryTrace;
use once_cell::sync::Lazy;
use scraper::{Html, Selector};
/// eg. <meta name="baggage" content="sentry-environment=production,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c,sentry-trace_id=fb5c42c8cff6fd39161dd245154ca599,sentry-org_id=4508179396558848,sentry-sampled=false,sentry-sample_rand=0.7208394686924251,sentry-sample_rate=0"/>
static SENTRY_TRACE_SELECTOR : Lazy<Selector> = Lazy::new(|| {
Selector::parse("meta[name=sentry-trace]")
.expect("HTML selector should parse")
});
/// Extract the sentry trace, then process it.
/// The Python library discards the latter half of the trace.
pub fn parse_index_sentry_trace(html: &str) -> Option<SentryTrace> {
get_meta_value(html)
.map(|trace| split_sentry_trace(&trace))
.map(|trace| SentryTrace(trace))
}
fn get_meta_value(html: &str) -> Option<String> {
let document = Html::parse_document(html);
let selected = document.select(&SENTRY_TRACE_SELECTOR);
let mut values = selected
.filter_map(|node| node.attr("content"))
.map(|s| s.to_string())
.collect::<Vec<_>>();
values.pop()
}
fn split_sentry_trace(trace: &str) -> String {
trace.split("-")
.next()
.map(|v| v.to_string())
.unwrap_or_else(|| trace.to_string())
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs};
use crate::requests::index_page::parsers::index::parse_index_sentry_trace::parse_index_sentry_trace;
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
mod test_request {
use super::*;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookie = get_test_cookies()?;
let index = get_index(GetIndexPageArgs {
cookie: &cookie,
}).await?;
let sentry_trace = parse_index_sentry_trace(&index.body);
println!("Sentry trace: {:?}", sentry_trace);
assert_eq!(1, 2);
Ok(())
}
}
mod test_sentry_split {
use crate::requests::index_page::parsers::index::parse_index_sentry_trace::split_sentry_trace;
#[test]
fn test_production_case() {
let result = split_sentry_trace("d6d7c55e4a489c0dabaed06e5c7b257b-d2391b601d54494f-0");
assert_eq!(&result, "d6d7c55e4a489c0dabaed06e5c7b257b");
}
#[test]
fn fallback_case_1() {
let result = split_sentry_trace("asdf");
assert_eq!(&result, "asdf");
}
#[test]
fn fallback_case_2() {
let result = split_sentry_trace("");
assert_eq!(&result, "");
}
}
}
@@ -1,52 +0,0 @@
use crate::datatypes::api::svg_path_data::SvgPathData;
use once_cell::sync::Lazy;
use scraper::{Html, Selector};
static SVG_PATH_SELECTOR: Lazy<Selector> = Lazy::new(|| {
Selector::parse("path[d]")
.expect("HTML selector should parse")
});
pub fn parse_svg_paths_from_index_html(html: &str) -> Vec<SvgPathData> {
// From https://github.com/realasfngl/Grok-Api :
// all_d_values = findall(r'"d":"(M[^"]{200,})"', html)
// svg_data = all_d_values[int(loading.split("loading-x-anim-")[1])]
let document = Html::parse_document(html);
let selected = document.select(&SVG_PATH_SELECTOR);
selected
.filter_map(|node| node.attr("d"))
.filter(|path_data| path_data.len() >= 200) // NB: Not sure why these SVG strokes are more important.
.map(|s| SvgPathData(s.to_string()))
.collect::<Vec<_>>()
}
// TODO: Get from JSON instead ? - That's how the python source does it.
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs};
use crate::requests::index_page::parsers::index::parse_index_svg_paths::parse_svg_paths_from_index_html;
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookie = get_test_cookies()?;
let index = get_index(GetIndexPageArgs {
cookie: &cookie,
}).await?;
let paths = parse_svg_paths_from_index_html(&index.body);
println!("Paths count: {:?}", paths.len());
for path in paths {
println!("{}", path.0);
}
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,50 +0,0 @@
use crate::datatypes::api::user_email::UserEmail;
use once_cell::sync::Lazy;
use regex::Regex;
/// Find the user email in the index.html payload
/// eg.
static JSON_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"\\?"email\\?":\s*\\?"([^"\\]+)\\?",?"#)
.expect("Regex should parse")
});
/// Parse the user email from the index.html
pub fn parse_index_user_email(html: &str) -> Option<UserEmail> {
let maybe_meta = scrape_user_email_via_regex(html);
if let Some(meta) = maybe_meta {
return Some(UserEmail(meta))
}
None
}
fn scrape_user_email_via_regex(html: &str) -> Option<String> {
let captures = JSON_REGEX.captures(html)?;
let capture = captures.get(1)?;
Some(capture.as_str().to_string())
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs};
use crate::requests::index_page::parsers::index::parse_index_user_email::parse_index_user_email;
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookie = get_test_cookies()?;
let index = get_index(GetIndexPageArgs {
cookie: &cookie,
}).await?;
let user_email = parse_index_user_email(&index.body);
println!("User Email : {:?}", user_email);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,50 +0,0 @@
use crate::datatypes::api::user_id::UserId;
use once_cell::sync::Lazy;
use regex::Regex;
/// Find the user id in the index.html payload
/// eg. \"userId\":\"85980643-ffab-4984-a3de-59a608c47d7f\",
static JSON_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"\\?"userI[dD]\\?":\s*\\?"([a-zA-Z0-9-]{25,})\\?",?"#)
.expect("Regex should parse")
});
/// Parse the user id from the index.html
pub fn parse_index_user_id(html: &str) -> Option<UserId> {
let maybe_meta = scrape_user_id_via_regex(html);
if let Some(meta) = maybe_meta {
return Some(UserId(meta))
}
None
}
fn scrape_user_id_via_regex(html: &str) -> Option<String> {
let captures = JSON_REGEX.captures(html)?;
let capture = captures.get(1)?;
Some(capture.as_str().to_string())
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs};
use crate::requests::index_page::parsers::index::parse_index_user_id::parse_index_user_id;
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookie = get_test_cookies()?;
let index = get_index(GetIndexPageArgs {
cookie: &cookie,
}).await?;
let user_id = parse_index_user_id(&index.body);
println!("User ID : {:?}", user_id);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,80 +0,0 @@
use crate::datatypes::api::verification_token::VerificationToken;
use once_cell::sync::Lazy;
use regex::Regex;
use scraper::{Html, Selector};
/// Find the verification token in the meta tags using HTML parsing.
static META_SELECTOR : Lazy<Selector> = Lazy::new(|| {
Selector::parse("meta[name=grok-site-verification]")
.expect("HTML selector should parse")
});
/// Find the verification token in the meta tags using regex
// eg. <meta name="grok-site-verification" content="iFlTOEJNfQZP1B6YMRf/zuj3eYFrKWi6eNUg5XvvpllTOw5TS82coZkeUBdiHxYr"/>
static META_TAG_REGEX : Lazy<Regex> = Lazy::new(|| {
Regex::new(r#""grok-site-verification"\s*content="([A-Za-z0-9/=+-]{5,})""#)
.expect("Regex should parse")
});
/// Find the verification token in the script tags using regex
/// eg. {\"name\":\"grok-site-verification\",\"content\":\"iFlTOEJNfQZP1B6YMRf/zuj3eYFrKWi6eNUg5XvvpllTOw5TS82coZkeUBdiHxYr\"}]
static JSON_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#""grok-site-verification\\?",\s*\\?"content\\?":\\?"([A-Za-z0-9/=+-]{5,})\\?""#)
.expect("Regex should parse")
});
/// Parse the verification token from the root HTML
pub fn parse_index_verification_token(html: &str) -> Option<VerificationToken> {
let maybe_meta = scrape_meta_tag_via_parsing(html);
if let Some(meta) = maybe_meta {
return Some(VerificationToken(meta))
}
let maybe_script = scrape_script_via_regex(html);
if let Some(script) = maybe_script {
return Some(VerificationToken(script))
}
None
}
fn scrape_script_via_regex(html: &str) -> Option<String> {
let captures = META_TAG_REGEX.captures(html)?;
let capture = captures.get(1)?;
Some(capture.as_str().to_string())
}
fn scrape_meta_tag_via_parsing(html: &str) -> Option<String> {
let document = Html::parse_document(html);
let selected = document.select(&META_SELECTOR);
let mut values = selected
.filter_map(|node| node.attr("content"))
.map(|s| s.to_string())
.collect::<Vec<_>>();
values.pop()
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page::{get_index, GetIndexPageArgs};
use crate::requests::index_page::parsers::index::parse_index_verification_token::parse_index_verification_token;
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // Manual test invocation
async fn test() -> AnyhowResult<()> {
let cookie = get_test_cookies()?;
let index = get_index(GetIndexPageArgs {
cookie: &cookie,
}).await?;
let result = parse_index_verification_token(&index.body);
println!("{:?}", result);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,2 +0,0 @@
pub mod index;
pub mod script;
@@ -1,2 +0,0 @@
pub mod parse_xsid_script_numbers;
pub mod parse_script_actions_and_xsid_script_path;
@@ -1,124 +0,0 @@
use crate::error::grok_client_error::GrokClientError;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
use log::info;
static ACTIONS_REGEX : Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"createServerReference\)\("([a-f0-9]+)""#)
.expect("Regex should parse")
});
/// 2025-10-01: "(static/chunks/[^"]+\.js)"[^}]*?a\(880932\)"
/// 2025-12-01: "(static/chunks/[^"]+\.js)"[^}]*?\(880932\)"
static XSID_SCRIPT_REGEX : Lazy<Regex> = Lazy::new(|| {
Regex::new(r#""(static/chunks/[^"]+\.js)"[^}]*?\(880932\)"#)
.expect("Regex should parse")
});
#[derive(Debug, Clone)]
pub struct ActionsAndXsid {
/// A list of opaque IDs for "actions"
pub actions: Vec<String>,
/// The URL path of the xsid script, with no leading slash.
pub xsid_script_path: String,
}
pub fn parse_script_actions_and_xsid_script_path(scripts: &HashMap<String, String>) -> Result<ActionsAndXsid, GrokClientError> {
let mut action_script_path = None;
let mut script_content_1 = None;
let mut script_content_2 = None;
for (script_path, script_body) in scripts.iter() {
if script_body.contains("anonPrivateKey") {
action_script_path = Some(script_path.to_string());
script_content_1 = Some(script_body.to_string());
} else if script_body.contains("880932") {
script_content_2 = Some(script_body.to_string());
}
}
// NB: Commented out because we don't use it.
// let action_script_path = match action_script_path {
// Some(action_script_path) => action_script_path,
// None => return Err(GrokClientError::ScriptLogicOutOfDate),
// };
let script_content_1 = match script_content_1 {
Some(script_content_1) => script_content_1,
None => return Err(GrokClientError::Script1LogicOutOfDate),
};
let script_content_2 = match script_content_2 {
Some(script_content_2) => script_content_2,
None => return Err(GrokClientError::Script2LogicOutOfDate),
};
//println!("Script content 1:");
//println!("{}", script_content_1);
//println!("Script content 2:");
//println!("{}", script_content_2);
let actions = ACTIONS_REGEX.captures_iter(&script_content_1)
.flat_map(|captures| captures.get(1).map(|m| m.as_str().to_string()))
.collect::<Vec<_>>();
let xsid_script_path = parse_xsid_script_path_from_turbopack_script(&script_content_2)?;
Ok(ActionsAndXsid {
actions,
xsid_script_path,
})
}
fn parse_xsid_script_path_from_turbopack_script(script_source: &str) -> Result<String, GrokClientError> {
let xsid_script_path = XSID_SCRIPT_REGEX.captures(&script_source)
.map(|captures| captures.get(1)
.map(|m| m.as_str()
.to_string()))
.flatten();
match xsid_script_path {
Some(xsid_script) => Ok(xsid_script),
None => Err(GrokClientError::ScriptXsidLogicOutOfDate),
}
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::get_index_page_and_scripts::{get_index_page_and_scripts, GetIndexPageAndScriptsArgs};
use crate::requests::index_page::parsers::script::parse_script_actions_and_xsid_script_path::{parse_script_actions_and_xsid_script_path, parse_xsid_script_path_from_turbopack_script};
use crate::test_utils::get_test_cookies::get_test_cookies;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // manually test
async fn test() -> AnyhowResult<()> {
//setup_test_logging(LevelFilter::Trace);
let cookie = get_test_cookies()?;
let page_and_scripts = get_index_page_and_scripts(GetIndexPageAndScriptsArgs {
cookie: &cookie,
}).await?;
let result = parse_script_actions_and_xsid_script_path(&page_and_scripts.scripts)?;
println!("{:?}", result);
assert_eq!(1, 2);
Ok(())
}
#[test]
fn parse_turbopack_script_2_for_xsid() {
// NB: The XSID parsing script broke on December 1st.
// The valid script to parse out is "static/chunks/6450f465e55b5c24.js" (as it contains the magic numbers). This comes before the string (880932)
// They probably just changed the TurboPack configs.
let example_script_2 = r#"
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,483347,a=>{a.v(s=>Promise.all(["static/chunks/6450f465e55b5c24.js"].map(s=>a.l(s))).then(()=>s(880932)))},330040,a=>{a.v(s=>Promise.all(["static/chunks/5cc0fc78839e38d4.js"].map(s=>a.l(s))).then(()=>s(921398)))},127660,a=>{a.v(s=>Promise.all(["static/chunks/fc24fa4ac57f0fa9.js"].map(s=>a.l(s))).then(()=>s(969999)))},307492,a=>{a.v(s=>Promise.all(["static/chunks/eb46b9f393cc69d9.js"].map(s=>a.l(s))).then(()=>s(529107)))},460973,a=>{a.v(s=>Promise.all(["static/chunks/7ff9ad06da182f94.js"].map(s=>a.l(s))).then(()=>s(296273)))},900864,
"#;
let parsed = parse_xsid_script_path_from_turbopack_script(example_script_2).unwrap();
assert_eq!(parsed, "static/chunks/6450f465e55b5c24.js".to_string());
}
}
@@ -1,27 +0,0 @@
use crate::datatypes::api::xsid_numbers::XsidNumbers;
use log::error;
use once_cell::sync::Lazy;
use regex::Regex;
use std::str::FromStr;
static NUMBERS_REGEX : Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"x\[(\d+)\]\s*,\s*16"#)
.expect("Regex should parse")
});
pub fn parse_xsid_script_numbers(script_body: &str) -> XsidNumbers {
let numbers = NUMBERS_REGEX.captures_iter(&script_body)
.flat_map(|captures| captures.get(1).map(|m| m.as_str().to_string()))
.flat_map(|num| match u32::from_str(&num) {
Ok(num) => Some(num),
Err(err) => {
error!("Error parsing: {:?}", err);
None
}
})
.collect::<_>();
XsidNumbers {
numbers,
}
}
@@ -1,63 +0,0 @@
use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use log::warn;
use wreq::Client;
const INDEX_URL: &str = "https://grok.com";
pub struct GetIndexPageScriptArgs<'a> {
pub client: &'a Client,
pub cookie: &'a str,
pub script_url: &'a str,
}
/// Get javascript that we'll need for client crypto purposes.
pub async fn get_index_page_script_with_client(args: GetIndexPageScriptArgs<'_>) -> Result<String, GrokError> {
let script_url;
if args.script_url.starts_with("https://") {
script_url = args.script_url.to_string();
} else {
script_url = get_script_url(args.script_url);
}
let builder = args.client.get(script_url)
.header("User-Agent", FIREFOX_143_MAC_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.5")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", "https://grok.com")
.header("Connection", "keep-alive")
.header("Sec-Fetch-Dest", "script")
.header("Sec-Fetch-Mode", "no-cors")
.header("Sec-Fetch-Site", "same-origin")
.header("TE", "trailers");
let response = builder.send()
.await
.map_err(|err| GrokClientError::WreqClientError(err))?;
let status = response.status();
// TODO: Cloudflare handling.
let body = response.text()
.await
.map_err(|err| GrokClientError::WreqClientError(err))?;
if !status.is_success() {
warn!("could not get index page script: {}, body: {}", status, body);
}
Ok(body)
}
fn get_script_url(script_path: &str) -> String {
if script_path.starts_with("/") {
format!("{}{}", INDEX_URL, script_path)
} else {
format!("{}/{}", INDEX_URL, script_path)
}
}
@@ -1,80 +0,0 @@
use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::requests::index_page::requests::get_index_page_script_with_client::{get_index_page_script_with_client, GetIndexPageScriptArgs};
use log::error;
use std::collections::HashMap;
use wreq::Client;
pub struct GetIndexPageScriptsArgs<'a> {
pub client: &'a Client,
pub cookie: &'a str,
pub scripts: &'a Vec<String>,
}
/// Get javascript that we'll need for client crypto purposes.
pub async fn get_index_page_scripts_with_client(args: GetIndexPageScriptsArgs<'_>) -> Result<HashMap<String, String>, GrokError> {
let mut map = HashMap::with_capacity(args.scripts.len());
for script_url in args.scripts {
let result = get_index_page_script_with_client(GetIndexPageScriptArgs {
client: args.client,
cookie: args.cookie,
script_url,
}).await;
let script = match result {
Ok(script) => script,
Err(err) => {
error!("Error fetching script: {}: {:?}", script_url, err);
return Err(err);
}
};
map.insert(script_url.to_string(), script);
}
Ok(map)
}
#[cfg(test)]
mod tests {
use crate::requests::index_page::parsers::index::parse_index_script_list::parse_index_svg_paths;
use crate::requests::index_page::requests::get_index_page_scripts_with_client::{get_index_page_scripts_with_client, GetIndexPageScriptsArgs};
use crate::requests::index_page::requests::get_index_page_with_client::{get_index_page_with_client, GetIndexPageWithClientArgs};
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::utils::create_firefox_client::create_firefox_client;
use errors::AnyhowResult;
#[tokio::test]
#[ignore] // manually test
async fn test() -> AnyhowResult<()> {
//setup_test_logging(LevelFilter::Trace);
let client = create_firefox_client()?;
let cookie = get_test_cookies()?;
let index = get_index_page_with_client(GetIndexPageWithClientArgs {
client: &client,
cookie: &cookie,
}).await?;
let scripts = parse_index_svg_paths(&index.body);
let scripts = get_index_page_scripts_with_client(GetIndexPageScriptsArgs {
client: &client,
cookie: &cookie,
scripts: &scripts,
}).await?;
for (k, v) in scripts.iter() {
let mut body = v.to_string();
body.truncate(100);
println!("{}: {}\n\n", k, body);
}
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,87 +0,0 @@
use crate::client::browser_user_agents::FIREFOX_143_MAC_USER_AGENT;
use crate::error::grok_client_error::GrokClientError;
use crate::error::grok_error::GrokError;
use crate::error::grok_generic_api_error::GrokGenericApiError;
use log::{info, warn};
use std::ops::Deref;
use wreq::header::{ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, ORIGIN, PRAGMA, REFERER, TE, UPGRADE, USER_AGENT};
use wreq::ws::message::Message;
use wreq::Client;
use wreq_util::Emulation;
const INDEX_URL: &str = "https://grok.com";
pub struct GetIndexPageWithClientArgs<'a> {
pub client: &'a Client,
pub cookie: &'a str,
}
pub struct IndexPage {
pub body: String,
}
pub async fn get_index_page_with_client(args: GetIndexPageWithClientArgs<'_>) -> Result<IndexPage, GrokError> {
let builder = args.client.get(INDEX_URL)
.header("User-Agent", FIREFOX_143_MAC_USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Accept-Language", "en-US,en;q=0.5")
.header("Connection", "keep-alive")
.header("Sec-Fetch-Dest", "document")
.header("Sec-Fetch-Mode", "navigate")
.header("Sec-Fetch-Site", "none")
.header("Sec-Fetch-User", "?1")
.header("Sec-GPC", "1")
.header("priority", "u=0, i")
.header("Pragma", "no-cache")
.header("Cache-Control", "no-cache")
.header("Cookie", args.cookie.to_string())
.header("TE", "trailers");
info!("Sending request for index page...");
let response = builder.send()
.await
.map_err(|err| GrokClientError::WreqClientError(err))?;
let status = response.status();
let body = response.text()
.await
.map_err(|err| GrokClientError::WreqClientError(err))?;
// TODO: Cloudflare handling
if !status.is_success() {
warn!("could not get index page script: {}, body: {}", status, body);
}
Ok(IndexPage {
body,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use crate::utils::create_firefox_client::create_firefox_client;
use errors::AnyhowResult;
use log::LevelFilter;
#[tokio::test]
#[ignore] // manually test
async fn test() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let cookie = get_test_cookies()?;
let client = create_firefox_client()?;
let result = get_index_page_with_client(GetIndexPageWithClientArgs {
client: &client,
cookie: &cookie,
}).await?;
println!("Body:\n\n");
println!("{}", result.body);
assert_eq!(1, 2);
Ok(())
}
}
@@ -1,3 +0,0 @@
pub (super) mod get_index_page_script_with_client;
pub (super) mod get_index_page_scripts_with_client;
pub (super) mod get_index_page_with_client;
@@ -1,186 +0,0 @@
use crate::datatypes::api::svg_path_data::SvgPathData;
use crate::datatypes::api::verification_token::VerificationToken;
use crate::datatypes::api::xsid_numbers::XsidNumbers;
use crate::error::grok_client_error::GrokClientError;
use crate::requests::index_page::signature::signature_xs::signature_xs;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use byteorder::{LittleEndian, WriteBytesExt};
use chrono::{TimeDelta, Utc};
use log::{debug, error};
use sha2::{Digest, Sha256};
use std::ops::Sub;
pub struct GenerateXsidArgs<'a> {
/// Path of the request,
/// eg. `/rest/app-chat/conversations/new`
pub path: &'a str,
/// HTTP Method
/// eg. `POST`
pub method: &'a str,
pub verification_token: &'a VerificationToken,
pub svg_data: &'a SvgPathData,
// "x_values"
pub numbers: &'a XsidNumbers,
}
pub fn generate_xsid(args: GenerateXsidArgs<'_>) -> Result<String, GrokClientError> {
let timestamp = Utc::now().timestamp();
let timestamp = timestamp as u32;
generate_xsid_with_timestamp(timestamp, args)
}
pub fn generate_xsid_with_timestamp(timestamp: u32, args: GenerateXsidArgs<'_>) -> Result<String, GrokClientError> {
/*
xsid: str = Signature.generate_sign('/rest/app-chat/conversations/new', 'POST', self.verification_token, self.svg_data, self.numbers)
@staticmethod
def generate_sign(path: str, method: str, verification: str, svg: str, x_values: list, time_n: int = None, random_float: float = None) -> str:
n = int(time() - 1682924400) if not time_n else time_n
t = pack('<I', n)
r = b64decode(verification)
o = Signature.xs(r, svg, x_values)
msg = "!".join([method, path, str(n)]) + "obfiowerehiring" + o
digest = sha256(msg.encode('utf-8')).digest()[:16]
prefix_byte = int(floor(random() if not random_float else random_float * 256))
assembled = bytes([prefix_byte]) + r + t + digest + bytes([3])
arr = bytearray(assembled)
if len(arr) > 0:
first = arr[0]
for i in range(1, len(arr)):
arr[i] = arr[i] ^ first
return b64encode(bytes(arr)).decode('ascii').replace('=', '')
*/
let n = timestamp - 1682924400;
debug!("[xsid] n = {}", n);
let mut t = vec![];
t.write_u32::<LittleEndian>(n).unwrap(); // TODO Unwrap
debug!("[xsid] t = {:?}", t);
let r = BASE64_STANDARD.decode(&args.verification_token.0)
.map_err(|err| {
error!("Decode verification_token failed. {}", err);
GrokClientError::FailedToDecodeVerificationToken(err)
})?; // TODO: Not sure this is right.
debug!("[xsid] r = {:?}", r);
// o = Signature.xs(r, svg, x_values)
let o = signature_xs(&r, &args.svg_data.0, &args.numbers.numbers)?;
debug!("[xsid] o = {:?}", o);
// msg = "!".join([method, path, str(n)]) + "obfiowerehiring" + o
let method = args.method;
let path = args.path;
let msg = format!("{method}!{path}!{n}obfiowerehiring{o}");
// POST!/rest/app-chat/conversations/new!78721673obfiowerehiring4320e30fd70a3d70a3d7028f5c28f5c28f6028f5c28f5c28f60fd70a3d70a3d700
debug!("[xsid] msg = {}", msg);
//digest = sha256(msg.encode('utf-8')).digest()[:16]
let digest_all_bytes = Sha256::digest(msg.as_bytes()).to_vec();
debug!("[xsid] digest_all_bytes = {:?}", digest_all_bytes);
if digest_all_bytes.len() < 16 {
error!("Digest is too short: {}", digest_all_bytes.len());
return Err(GrokClientError::BadSignatureInputs);
}
let digest : &[u8] = &digest_all_bytes[.. 16];
debug!("[xsid] digest = {:?}", digest);
// prefix_byte = int(floor(random() if not random_float else random_float * 256))
let prefix_byte = 0; // NB: This is bad code? floor(random) = 0 always.
// assembled = bytes([prefix_byte]) + r + t + digest + bytes([3])
let mut assembled = vec![prefix_byte];
assembled.extend(r);
assembled.extend(t);
assembled.extend(digest);
assembled.extend([3]);
debug!("[xsid] assembled = {:?}", assembled);
/*
arr = bytearray(assembled)
if len(arr) > 0:
first = arr[0]
for i in range(1, len(arr)):
arr[i] = arr[i] ^ first
*/
let mut arr = assembled.clone();
// NB: Does not appear to be needed because the first byte is always "0" (for now)
//if arr.len() > 0 {
// let first = *arr.get(0).ok_or(GrokClientError::BadSignatureInputs)?;
// for i in 1..arr.len() {
// let mut a = arr.get_mut(i).ok_or(GrokClientError::BadSignatureInputs)?;
// let av = *a;
// let bv = av ^ first;
// *a = bv;
// }
//}
debug!("[xsid] arr = {:?}", arr);
// Uses + and / for base64
// encoded = b64encode(bytes(arr)).decode('ascii').replace('=', '')
let encoded_bytes = BASE64_STANDARD.encode(arr);
let encoded_bytes = encoded_bytes.replace("=", "");
debug!("[xsid] encoded_bytes = {:?}", encoded_bytes);
Ok(encoded_bytes)
}
#[cfg(test)]
mod tests {
use crate::datatypes::api::svg_path_data::SvgPathData;
use crate::datatypes::api::verification_token::VerificationToken;
use crate::datatypes::api::xsid_numbers::XsidNumbers;
use crate::requests::index_page::signature::generate_xsid::{generate_xsid_with_timestamp, GenerateXsidArgs};
use errors::AnyhowResult;
// TODO: More test cases
#[test]
fn test() -> AnyhowResult<()> {
let ver = VerificationToken("yt16CWiUI42s7wGGeMxcdEIT2miPF5PVOGZF1Jcae7K7HMQrLeI4RTYufmdfBIZU".to_string());
let svg_data = "M 10,30 C 147,16 51,222 136,87 h 31 s 177,77 40,114 C 68,166 18,188 114,44 h 220 s 148,210 202,25 C 220,36 246,218 12,120 h 244 s 34,154 56,161 C 211,204 208,60 174,14 h 155 s 157,116 220,95 C 116,164 53,210 174,232 h 139 s 148,151 135,221 C 167,196 118,27 149,247 h 233 s 129,238 113,21 C 73,44 215,158 218,29 h 68 s 13,98 243,134 C 44,98 149,35 20,178 h 163 s 219,104 41,152 C 237,107 118,120 127,117 h 57 s 41,167 43,136 C 116,168 44,246 182,209 h 143 s 238,93 237,119 C 142,36 64,67 97,111 h 71 s 230,247 119,27 C 206,56 158,51 71,115 h 135 s 120,186 74,57 C 197,165 180,214 102,16 h 88 s 252,48 189,166 C 163,237 57,253 154,60 h 96 s 84,56 15,66 C 177,101 10,202 185,227 h 29 s 204,55 115,219 C 103,82 127,143 194,99 h 102 s 179,63 84,110";
let svg_data = SvgPathData(svg_data.to_string());
let numbers = vec![40, 18, 3, 18];
let numbers = XsidNumbers { numbers };
let timestamp = 1761646073;
let observed = generate_xsid_with_timestamp(timestamp, GenerateXsidArgs {
path: "/rest/app-chat/conversations/new",
method: "POST",
verification_token: &ver,
svg_data: &svg_data,
numbers: &numbers,
})?;
let expected = "AMrdeglolCONrO8BhnjMXHRCE9pojxeT1ThmRdSXGnuyuxzEKy3iOEU2Ln5nXwSGVIkysQRoLOMjee3yLtjtJXbPVwjgAw";
assert_eq!(&observed, expected);
Ok(())
}
}
@@ -1,8 +0,0 @@
pub mod signature_xs;
pub mod generate_xsid;
mod signature_xa;
mod signature_simulate_style;
mod signature_h;
mod signature_cubic_bezier_eased;
mod signature_to_hex;
mod round_two_decimals;
Loaded 100 of 1520 files, more files were not shown because too many files have changed in this diff. Show more