Generates a square profile image with Google Gemini (GEMINI_IMAGE_MODEL,
default gemini-3.1-flash-image) from a random base photo and prompts for
today’s weekday (see prompts.json), or from the holidays prompt list
when today’s calendar date appears in your optional vacations.json calendar.
The Gemini reply is converted to
PNG; before Slack upload the image is center-cropped to a square, resized
to 1024×1024, and saved under output/ with a timestamped filename, then
users.setPhoto is called.
Optional Slack job titles (UPDATE_SLACK_TITLE environment variable): after
the photo uploads successfully, the job can call Gemini with a text-only
model (GEMINI_TEXT_MODEL, default gemini-2.5-flash), generate one
satirical corporate-style phrase, and update Slack’s title field (Cargo /
Job title) via users.profile.set. Enable it by setting UPDATE_SLACK_TITLE
to 1, true, yes, or on in .env (see Environment variables below).
This step is best-effort: token scope, workspace policies, SCIM/HRIS, or Slack
API quirks can make the call return success while the visible title does not
match what was sent, or block updates entirely.
If that step fails, the job logs a warning and exits 0 without changing the prior title.
- Python 3.12
- Slack user token (
xoxp-…) withusers.profile:write - Gemini API key from Google AI Studio
- Create an app at api.slack.com/apps.
- OAuth & Permissions → User Token Scopes → add
users.profile:write. - Install (or reinstall) the app after scope changes and copy the User OAuth Token.
Profile updates via API also require Configure Profiles in the workspace admin (Data source → API) per Slack docs; Enterprise Grid workspaces may forbid members changing their own profile via API (Org users cannot change their own profile details).
cd auto-slack-avatar
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
cp .env.example .env
# Edit .env with real tokens and TZUse your clone directory if the folder name differs.
Put source photos in assets/images/ (.png, .jpg, .jpeg, .webp, .heic, .heif).
Only files directly inside that folder are used (no subfolders).
Edit prompts.json: a single JSON object with no extra keys. Required:
base_prompt: string (non-empty). Shared instructions for every run (Slack avatar constraints, style rules, etc.).monday…sunday: each value is a JSON array of strings; each day must have at least one non-empty string after trimming.holidays: JSON array of strings (at least one non-empty entry after trimming). Used only when today’s date matches an entry invacations.json(see Vacation days below); otherwise weekday prompts apply.
There are no cross-references between sources: the prompts file (prompts.json
by default, override PROMPTS_PATH) does not name image paths, and files under
assets/images/ (override ASSETS_DIR) do not reference the JSON. They are two
independent inputs for every run.
prompts.jsonprovides text only — the editing instructions Gemini follows (base_prompt, then one randomly chosen entry from today'sholidaysor weekday array).- The asset directory provides pixels only — the base photo Gemini edits.
Gemini receives one randomly chosen eligible image file plus that combined prompt (no fixed pairing between a specific prompt line and a specific filename).
Add more qualified images under the asset directory if you want more variety in which photo
runs pick; add more strings under a weekday array if you want more variety in how that day's
instructions read. Exact random.choice order after RUN_SEED / timestamp seeding
is spelled out under Randomness.
The text sent to Gemini is base_prompt, a blank line, then the chosen
weekday or holiday string (base + day-specific stylistic prompt).
Gemini may return image bytes in the model’s format; the code opens the result with Pillow and saves PNG (RGBA) before the Slack resize step.
Optional personal calendar: when today (in vacations.json → timezone)
matches a date in dates, the run picks from holidays in
prompts.json instead of the weekday list.
cp vacations.example.json vacations.json
# Edit timezone and dates in vacations.jsonvacations.jsonis gitignored (like.envand photos underassets/images/). The repo ships onlyvacations.example.jsonas a template.- Override the path with
VACATIONS_PATHin.env(defaultvacations.json).
{
"timezone": "America/Santiago",
"dates": [
"2026-07-15",
"2026-07-16"
]
}| Field | Required (if file exists) | Description |
|---|---|---|
timezone |
Yes | IANA zone, e.g. America/Santiago. Defines which calendar day counts as “today” when matching dates. |
dates |
Yes | List of YYYY-MM-DD strings (no time component). [] means no vacation days are active. |
Two settings control different things:
| Config | Used for |
|---|---|
TZ (.env) |
Weekday key (monday…sunday), log timestamps, output filename avatar_YYYY-MM-DD_HHMMSS.png |
vacations.json → timezone |
Only the “is today a vacation day?” check against dates |
Example: a Cloud Run Job with TZ=UTC can still use
"timezone": "America/Santiago" in vacations.json so July 15 in Chile
triggers holidays, even when UTC is still July 14 near midnight.
- Recommendation: set
vacations.timezoneto your real-life IANA zone (e.g.America/Santiago).TZmay match or differ depending on how you want logs and output filenames labeled. - No fallback: if
vacations.jsonexists,timezonemust be a valid IANA name; invalid values fail at startup with a clear error.
- No
vacations.json→ weekday-only behavior (no error). - File present,
dates: []→ no day is treated as vacation; the file can act as a placeholder until you add dates.
docker-compose.yml bind-mounts ./vacations.json into
the container. If the file is missing, make build-local and make run-local copy vacations.example.json first so
the mount and image build succeed.
Before make deploy (or make docker-push):
- Create or edit
vacations.jsonon the machine that runsdocker build(same repo checkout used inside thedeploycontainer). - The file is not committed to git but is
COPY’d into the app image (Dockerfile) and pushed to Artifact Registry. - After changing vacation dates locally, rebuild and push the image so Cloud Run picks up the new list.
Production vacation behavior always reflects the vacations.json baked into
the last deployed image, not a separate GCP config.
The process uses random.seed:
- If
RUN_SEEDis set in.env(integer), that value is used. - Otherwise
run_seed = int(time.time())at startup.
Order after seeding: optional vacation check (if vacations.json exists) →
resolve weekday from TZ (when not a vacation day) → random.choice on
the active prompt list (holidays or that weekday) → random.choice on the
image file list. With
UPDATE_SLACK_TITLE, Gemini’s phrase is trimmed when needed so it fits Slack’s length
constraints. Successful users.profile.set calls log profile['title']
as echoed by Slack. The seed is logged so you can replay image/prompt picks for
debugging.
Processed PNGs are written to output/ as:
avatar_YYYY-MM-DD_HHMMSS.png using the local time in TZ (e.g.
avatar_2026-05-16_143052.png).
From the project root (with venv activated):
python -m src.run_dailyNo cron inside the container; run when you want an update:
docker compose run --rm avatar-jobEquivalent: make build-local then make run-local from the repo root (host only needs Docker).
Ensure .env exists and mount paths in docker-compose.yml match your machine.
The production shape is a Cloud Run Job (one container run per trigger), an image in Artifact Registry, and Cloud Scheduler hitting the Job run API on a cron you configure in .env. Secret Manager is not used; the Makefile injects env vars from your .env into the Job at deploy time.
On your machine you only need Docker and Docker Compose—no host install of gcloud. Deploy tooling runs inside a second image (Dockerfile.deploy) via the deploy service in docker-compose.yml.
-
Copy
.env.exampleto.envand fill application variables plus GCP / Scheduler variables (GCP_PROJECT,GCP_REGION,AR_REPO,IMAGE_NAME,IMAGE_TAG,CLOUD_RUN_JOB_NAME,SCHEDULER_*, etc.). Never commit.env. -
Enable GCP APIs once as a project Owner (or another principal with
serviceusage.services.enable). A deploy service account cannot enable APIs for you. Example:gcloud services enable \ cloudresourcemanager.googleapis.com \ serviceusage.googleapis.com \ run.googleapis.com \ artifactregistry.googleapis.com \ cloudscheduler.googleapis.com \ --project=YOUR_PROJECT_IDOr use APIs & Services → Enable APIs in the Cloud Console. Deploy scripts call
scripts/check-required-gcp-apis.shand exit early with the samegcloud services enablehint if something is missing. -
Create the Artifact Registry Docker repository (see earlier errors if it is missing). Then wire two different service accounts in IAM:
- Deploy service account (the one whose JSON key you mount for
make deploy/docker-push): must be able to push images, deploy the Cloud Run Job, and create/update Cloud Scheduler jobs (the script runsgcloud scheduler jobs create|update). Typical project-level roles:roles/artifactregistry.writer,roles/run.developer(orroles/run.admin), androles/cloudscheduler.admin(coverscloudscheduler.jobs.create/update; a narrow custom role is possible but admin is the usual choice). - Scheduler invoker (
SCHEDULER_SERVICE_ACCOUNTin.env): the account Cloud Scheduler uses for OAuth when it POSTs to the Cloud Run Job run URL. It needs permission to run that job, e.g.roles/run.developeror a custom role includingrun.jobs.run. It does not replace the deploy SA fordeploy-scheduler.sh.
Example (replace
DEPLOY_SA_EMAILwith your key’s service account):PROJECT_ID=your-gcp-project-id DEPLOY_SA_EMAIL=your-deploy-sa@${PROJECT_ID}.iam.gserviceaccount.com gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ --member="serviceAccount:${DEPLOY_SA_EMAIL}" --role="roles/cloudscheduler.admin"
- Deploy service account (the one whose JSON key you mount for
-
Authenticate for
gcloud/ Artifact Registry (pick one):- Service account key (recommended): download a JSON key from GCP IAM, save it as
credentials/gcp-sa.json(path is gitignored). In.envset:COMPOSE_FILE=docker-compose.yml:docker-compose.gcp-sa.yml
Optionally setGCP_SERVICE_ACCOUNT_KEYif the file lives elsewhere on the host (default./credentials/gcp-sa.json). The fragment mounts it read-only at/workspace/credentials/gcp-sa.jsonand setsGOOGLE_APPLICATION_CREDENTIALS; deploy scripts callgcloud auth activate-service-account, so the host user’s gcloud login is not used. Do not paste the JSON into.env—only the host path / compose wiring.
- Human user login: add
docker-compose.gcloud-user.ymltoCOMPOSE_FILE(e.g.docker-compose.yml:docker-compose.gcloud-user.yml) so~/.config/gcloudis mounted from the host, then rundocker compose run --rm deploy gcloud auth loginonce. Do not mergedocker-compose.gcp-sa.ymlunless you intend to use a key as well.
- Service account key (recommended): download a JSON key from GCP IAM, save it as
-
Build the deploy image once if needed:
docker compose build deploy. -
Full rollout (build/push app image, update Job, create/update Scheduler):
docker compose run --rm deploy make deploy
With a service account (after
COMPOSE_FILEincludes the fragment), the same command works; Compose mergesdocker-compose.gcp-sa.ymlautomatically whenCOMPOSE_FILEis set in.env.Convenience on the host:
make deploy-compose-sarunsmake deployinside the deploy container with the SA compose files.Other targets (run inside the same
deployservice):make docker-push,make deploy-job,make deploy-scheduler,make job-run(one-off Job execution with--wait).
The default docker-compose.yml mounts the repo and the Docker socket (build/push use the host Docker daemon). It does not mount ~/.config/gcloud or ~/.docker so deploy runs do not inherit a random user login from the host. gcloud auth configure-docker writes /root/.docker inside the container; the host Docker daemon still performs docker push via the socket.
Makefile and scripts/ are intended to run inside the deploy container. The app Dockerfile must not COPY .env; .env is only for local/compose and deploy injection.
For agent-oriented commands and conventions, see AGENTS.md.
| Variable | Description |
|---|---|
SLACK_USER_TOKEN |
User OAuth token (xoxp-…). |
GEMINI_API_KEY |
Gemini Developer API key. |
GEMINI_IMAGE_MODEL |
Model id (default gemini-3.1-flash-image). |
GEMINI_TEXT_MODEL |
Text-capable Gemini id for UPDATE_SLACK_TITLE only (default gemini-2.5-flash). |
UPDATE_SLACK_TITLE |
Optional. If 1 / true / yes / on, generate and push Cargo / job title after each successful users.setPhoto, including runs that fall back to the raw base photo (no AI avatar) — text generation is still attempted. May not apply or may disagree with the UI depending on workspace controls and Slack behavior; treat as experimental. |
TZ |
IANA zone, e.g. America/Santiago. Drives weekday selection, logs, and output filenames. Does not replace vacations.json → timezone for vacation-day matching. |
RUN_SEED |
Optional integer to fix random choices. |
STRICT_GEMINI |
If 1 / true / yes / on, the run fails when Gemini hits quota or certain rate limits. If unset (default), some 429 / exhausted quota cases fall back to uploading the raw base photo (no AI edit). See .env.example. |
ASSETS_DIR |
Default assets/images. |
PROMPTS_PATH |
Default prompts.json. |
VACATIONS_PATH |
Default vacations.json. Path to the optional vacation calendar; if the file is missing, vacation checks are skipped. |
OUTPUT_DIR |
Default output. |
See .env.example for GCP_*, AR_REPO, IMAGE_*, CLOUD_RUN_*, SCHEDULER_*, and optional GCP_SERVICE_ACCOUNT_KEY / COMPOSE_FILE + docker-compose.gcp-sa.yml for service-account auth. Application variables above are the ones passed to Cloud Run (runtime); deploy-only keys are read by scripts/ and must not be required inside the Python process.
ruff check src
ruff format src- GCP: use Cloud Scheduler with
make deploy(ormake deploy-scheduler) so the schedule stays in sync with.env(SCHEDULER_CRON,SCHEDULER_TIME_ZONE). - Elsewhere: GitHub Actions
schedule, hostcron, ordocker compose run avatar-jobas a one-shot.
cloudscheduler.jobs.createPERMISSION_DENIED ondeploy-scheduler: the deploy service account (JSON used bymake deploy) must manage Scheduler resources—grantroles/cloudscheduler.adminat project level (see step 3). This is separate fromSCHEDULER_SERVICE_ACCOUNT, which only invokes the Cloud Run Job when the schedule fires.SERVICE_DISABLED/ Cloud Resource Manager / “Permission denied to enable service”: enable the APIs in the Console, or run thegcloud services enable …block in the Deploy to GCP section as a human Owner—not with the deploy service account.Permission deniedon~/.docker/config.jsonwhen runningdocker composeon the host: the host Docker CLI reads that file before starting containers; fix ownership withchown/chmodon the host (this is unrelated to in-container/root/.docker).- Deploy with service account: put the JSON in
credentials/gcp-sa.json, setCOMPOSE_FILE=docker-compose.yml:docker-compose.gcp-sa.ymlin.env, then run compose (ormake deploy-compose-sa). If the file is missing, the container will fail to start the bind mount. - Gemini returns no image: model or region may not support image output;
try another
GEMINI_IMAGE_MODELfrom the current AI Studio docs. - Quota / 429: with default settings the job may upload your original
base file if Gemini refuses; set
STRICT_GEMINI=1to fail the run instead. prompts.jsonerrors: unknown keys, missingbase_prompt, missingholidays, or empty weekday / holiday lists are rejected at startup.vacations.jsonerrors: invalid JSON, unknown keys, missingtimezoneordates, invalid IANA timezone, or dates not inYYYY-MM-DDformat.- Vacation prompts not used: check logs for
vacation_today=falseandvacations_timezone=…. Ensure the date indatesmatches the calendar day invacations.timezone, not necessarily the day inTZ. - Vacations work in GCP but not locally (or the reverse): the deployed
image embeds whatever
vacations.jsonwas present atdocker buildtime; edit the file and redeploy to sync production. - Slack
bad_image: rare if the post-process step ran; the code expects a decodable raster and outputs 1024×1024 square PNG for Slack. - Optional Slack titles (
UPDATE_SLACK_TITLE): off by default; turn on via env when you want the extra step. Even when enabled,titleupdates can fail silently or disagree with the Slack UI (workspace admin settings, SSO/SCIM, Enterprise rules, or token/user mismatch). - Title mismatch after
ok:true: the run printsdescribe_slack_token_userbefore writing the phrase —login=/user_id=must be Camilo Henríquez’s account. If another user installs the app,users.profile.setupdates THAT user’s Cargo, while the modal you screenshot belongs to yours (old «programador…» unchanged).