Skip to content

Commit 3fe3bcc

Browse files
author
Yetibot
committed
Merge branch 'master' into veo-banana-budget
2 parents cbb2bc3 + 3f26dec commit 3fe3bcc

14 files changed

Lines changed: 760 additions & 76 deletions

File tree

src/yetibot/core/adapters/discord.clj

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@
108108
(guild-channels)))
109109

110110
(defn- generated-image-info [msg]
111-
(when-let [[_ id ext] (re-matches #".*/generated-images/([^.]+)\.(png|gif|mp4)$" (str/trim msg))]
112-
{:id id :ext ext}))
111+
(when-let [[url id ext] (re-find #"https?://\S+?/generated-images/([^./\s]+)\.(png|gif|mp4)" msg)]
112+
{:url url :id id :ext ext}))
113113

114114
(def discord-max-message-length 2000)
115115

@@ -119,13 +119,23 @@
119119

120120
(defn- send-msg [{:keys [conn]} msg]
121121
(try
122-
(if-let [{:keys [id ext]} (generated-image-info msg)]
123-
(when-let [{:keys [data]} (get @images/image-store id)]
122+
(if-let [{:keys [url id ext]} (generated-image-info msg)]
123+
(if-let [{:keys [data]} (get @images/image-store id)]
124124
(let [bytes (.decode (Base64/getDecoder) ^String data)
125-
stream (java.io.ByteArrayInputStream. bytes)]
126-
@(messaging/create-message!
127-
(:rest @conn) chat/*target*
128-
:stream {:content stream :filename (str id "." ext)})))
125+
stream (java.io.ByteArrayInputStream. bytes)
126+
clean-msg (str/replace msg url "")]
127+
(if (str/blank? clean-msg)
128+
@(messaging/create-message!
129+
(:rest @conn) chat/*target*
130+
:stream {:content stream :filename (str id "." ext)})
131+
@(messaging/create-message!
132+
(:rest @conn) chat/*target*
133+
:content clean-msg
134+
:stream {:content stream :filename (str id "." ext)})))
135+
(if (> (count msg) discord-max-message-length)
136+
(doseq [chunk (chunk-message msg)]
137+
@(messaging/create-message! (:rest @conn) chat/*target* :content chunk))
138+
@(messaging/create-message! (:rest @conn) chat/*target* :content msg)))
129139
(if (> (count msg) discord-max-message-length)
130140
(doseq [chunk (chunk-message msg)]
131141
@(messaging/create-message! (:rest @conn) chat/*target* :content chunk))

src/yetibot/core/commands/agent.clj

Lines changed: 310 additions & 23 deletions
Large diffs are not rendered by default.

src/yetibot/core/commands/veo.clj

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
11
(ns yetibot.core.commands.veo
2-
(:require [taoensso.timbre :refer [info error]]
2+
(:require [clojure.string :as string]
3+
[taoensso.timbre :refer [info error]]
34
[yetibot.core.hooks :refer [cmd-hook]]
45
[yetibot.core.util.image-input :as image-input]
56
[yetibot.core.util.gemini :as gemini]
7+
[yetibot.core.chat :as chat]
68
[yetibot.core.webapp.routes.images :refer [store-image!]]))
79

10+
(defn redact
11+
"Strip a leaked Gemini API key from an error message before it reaches chat."
12+
[msg]
13+
(some-> msg (string/replace #"key=[^\s&\"]+" "key=***")))
14+
15+
(def model-presets
16+
{"lite" {:model "veo-3.1-lite-generate-preview" :duration 4}
17+
"fast" {:model "veo-3.1-fast-generate-preview" :duration 4}
18+
"gigaveo" {:model "veo-3.1-generate-preview" :duration 8}
19+
"preview" {:model "veo-3.1-generate-preview" :duration 8}
20+
"better" {:model "veo-3.1-generate-preview" :duration 8}})
21+
22+
(defn parse-model-and-prompt
23+
[raw-prompt]
24+
(let [words (string/split (string/trim raw-prompt) #"\s+" 2)
25+
first-word (string/lower-case (or (first words) ""))
26+
preset (get model-presets first-word)]
27+
(if (and preset (> (count words) 1))
28+
(assoc preset :prompt (second words))
29+
{:prompt raw-prompt})))
30+
831
(defn veo-budget-cmd
932
"veo budget # show monthly budget status"
1033
{:yb/cat #{:info}}
@@ -24,29 +47,60 @@
2447

2548
(defn veo-cmd
2649
"veo <prompt> # generate a short AI video with Veo
50+
gigaveo <prompt> # generate a high-quality 8-second video with flagship Veo 3.1 model
2751
2852
Examples:
2953
veo a cat jumping
30-
veo a robot dancing in times square
31-
veo make @someone breakdance"
54+
veo lite a cat jumping
55+
veo gigaveo a cat jumping
56+
gigaveo a cat jumping"
3257
{:yb/cat #{:img :gif}}
33-
[{:keys [match chat-source]}]
58+
[{:keys [match chat-source user cmd]}]
3459
(if (gemini/configured?)
3560
(try
36-
(let [{:keys [prompt image-urls]} (image-input/extract-images match chat-source)]
37-
(info "veo: generating video for:" prompt "with" (count image-urls) "input image(s)")
38-
(let [video (gemini/generate-video prompt image-urls)
39-
id (store-image! video)
40-
url (format "%s/generated-images/%s.mp4" (gemini/yetibot-base-url) id)]
41-
(info "veo: video generated, serving at" url)
42-
{:result/value url
43-
:result/data {:id id :prompt match :url url}}))
61+
(let [{:keys [prompt image-urls]} (image-input/extract-images match chat-source)
62+
preset (if (= cmd "gigaveo")
63+
(assoc (get model-presets "gigaveo") :prompt prompt)
64+
(let [parsed (parse-model-and-prompt prompt)]
65+
(when (:model parsed)
66+
parsed)))
67+
final-prompt (if preset (:prompt preset) prompt)
68+
model (if preset (:model preset) (gemini/veo-model))
69+
duration (if preset (:duration preset) (gemini/veo-duration))
70+
adapter chat/*adapter*
71+
target chat/*target*
72+
thread-ts chat/*thread-ts*
73+
user-mention (when-let [user-id (:id user)] (str "<@" user-id ">"))]
74+
(info "veo: starting async video generation for:" final-prompt
75+
"model:" model "duration:" duration "with" (count image-urls) "input image(s)")
76+
(future
77+
(binding [chat/*adapter* adapter
78+
chat/*target* target
79+
chat/*thread-ts* thread-ts]
80+
(try
81+
(let [video (gemini/generate-video final-prompt image-urls model duration)
82+
id (store-image! video)
83+
url (format "%s/generated-images/%s.mp4" (gemini/yetibot-base-url) id)
84+
cost (gemini/calculate-video-cost model duration)
85+
cost-str (format " (Cost: $%.2f)" cost)
86+
msg (if user-mention (str user-mention ": " url cost-str) (str url cost-str))]
87+
(info "veo: video generated, serving at" url "cost:" cost)
88+
(chat/send-msg msg))
89+
(catch Exception e
90+
(error "veo: generation error in future:" (.getMessage e))
91+
(let [err-msg (str "Video generation failed: " (redact (.getMessage e)))
92+
msg (if user-mention (str user-mention ": " err-msg) err-msg)]
93+
(chat/send-msg msg))))))
94+
{:result/value (str "🎥 Bonzi Buddy start generating video for \"" final-prompt "\". This take some time (30s to 3m)...")})
4495
(catch Exception e
45-
(error "veo: generation error:" (.getMessage e))
46-
{:result/error (str "Video generation failed: " (.getMessage e))}))
96+
(error "veo: initialization error:" (.getMessage e))
97+
{:result/error (str "Video generation initialization failed: " (redact (.getMessage e)))}))
4798
{:result/error
4899
"Gemini API is not configured. Set `gemini.key` in config."}))
49100

50101
(cmd-hook #"veo"
51102
#"budget" veo-budget-cmd
52103
#".+" veo-cmd)
104+
105+
(cmd-hook #"gigaveo"
106+
#".+" veo-cmd)

src/yetibot/core/db/agent_run.clj

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
(ns yetibot.core.db.agent-run
2+
"In-flight `!agent` runs. A row exists only while a run is running; the run
3+
deletes its own row on any terminal outcome (success/error/timeout). A row
4+
left behind means the JVM was killed mid-run (a restart) — those are the runs
5+
resumed on the next boot."
6+
(:require [yetibot.core.db.util :as db.util]))
7+
8+
(def schema
9+
{:schema/table "agent_run"
10+
:schema/specs (into [[:run-id :text "NOT NULL"]
11+
[:request :text "NOT NULL"]
12+
[:target :text]
13+
[:context-channel :text]
14+
[:status-id :text]
15+
[:adapter-uuid :text]
16+
[:mentions :text]
17+
[:on-discord :boolean "NOT NULL" "DEFAULT false"]
18+
[:attempts :integer "NOT NULL" "DEFAULT 1"]]
19+
(db.util/default-fields))})
20+
21+
(def create (partial db.util/create (:schema/table schema)))
22+
23+
(def query (partial db.util/query (:schema/table schema)))
24+
25+
(def update-where (partial db.util/update-where (:schema/table schema)))
26+
27+
(def delete (partial db.util/delete (:schema/table schema)))
28+
29+
(def find-all (partial db.util/find-all (:schema/table schema)))

src/yetibot/core/util/command.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@
102102
otherwise nil"
103103
([body] (extract-command body config-prefix))
104104
([body prefix]
105-
(re-find (re-pattern (str "^\\" prefix "(.+)")) body)))
105+
(re-find (re-pattern (str "(?s)^\\" prefix "(.+)")) body)))
106106

107107
(defn embedded-cmds
108108
"Parse a string and only return a collection of any embedded commands instead

src/yetibot/core/util/gemini.clj

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -297,30 +297,47 @@
297297

298298
(defn veo-model [] (or (-> config :veo :model) default-veo-model))
299299

300-
(defn- veo-duration [] (long (or (parse-number (-> config :veo :duration)) 4)))
300+
(defn veo-duration [] (long (or (parse-number (-> config :veo :duration)) 4)))
301301

302-
(defn- veo-cost-per-second []
302+
(defn- veo-cost-per-second-for-model [model]
303303
(or (parse-number (-> config :veo :cost-per-second))
304304
(cond
305-
(string/includes? (veo-model) "lite") 0.05
306-
(string/includes? (veo-model) "fast") 0.15
305+
(string/includes? model "lite") 0.05
306+
(string/includes? model "fast") 0.15
307307
:else 0.40)))
308308

309+
(defn calculate-video-cost
310+
"Calculate the estimated cost in USD of generating a video of the specified
311+
duration using the given model."
312+
[model duration]
313+
(* duration (veo-cost-per-second-for-model model)))
314+
315+
(defn- veo-cost-per-second [] (veo-cost-per-second-for-model (veo-model)))
316+
317+
(defn- veo-cost-units-for-model [model duration]
318+
(max 1 (long (Math/ceil (/ (* duration (veo-cost-per-second-for-model model))
319+
(cost-per-image))))))
320+
309321
(defn- veo-cost-units
310322
"Express a Veo clip's dollar cost as a count of image-equivalents so it draws
311323
down the shared monthly budget proportionally."
312324
[]
313-
(max 1 (long (Math/ceil (/ (* (veo-duration) (veo-cost-per-second))
314-
(cost-per-image))))))
325+
(veo-cost-units-for-model (veo-model) (veo-duration)))
315326

316327
(def ^:private veo-poll-interval-ms 6000)
317328
(def ^:private veo-max-polls 50) ; ~5 min ceiling
318329

330+
;; Cap connect + read time on every Veo HTTP call so a stalled Google API surfaces
331+
;; as an error instead of hanging the run forever — an otherwise silent failure
332+
;; where the user gets neither a video nor an error.
333+
(def ^:private veo-http-timeouts {:connection-timeout 10000 :socket-timeout 120000})
334+
319335
(defn- veo-image-part
320336
"Fetch an image URL and return a Veo instance image (base64), or nil. Used for
321337
image-to-video — e.g. a mentioned Discord user's avatar becomes the opening frame."
322338
[image-url]
323-
(let [resp (client/get image-url {:as :byte-array :throw-exceptions false})
339+
(let [resp (client/get image-url (merge veo-http-timeouts
340+
{:as :byte-array :throw-exceptions false}))
324341
mime (first (string/split (get-in resp [:headers "Content-Type"] "image/png") #";"))]
325342
(when (<= 200 (:status resp) 299)
326343
{:mimeType mime
@@ -329,14 +346,17 @@
329346
(defn- veo-start
330347
"Kick off a Veo generation; returns the long-running operation name.
331348
When `image` is provided, Veo conditions the video on it (image-to-video)."
332-
[prompt image]
333-
(let [url (format "%s/models/%s:predictLongRunning?key=%s" api-base (veo-model) (:key config))
349+
[prompt image model duration]
350+
(let [model (or model (veo-model))
351+
duration (or duration (veo-duration))
352+
url (format "%s/models/%s:predictLongRunning?key=%s" api-base model (:key config))
334353
body {:instances [(cond-> {:prompt prompt} image (assoc :image image))]
335-
:parameters {:aspectRatio "16:9" :durationSeconds (veo-duration)}}
336-
resp (client/post url {:content-type :json
337-
:body (json/write-str body)
338-
:as :json
339-
:throw-exceptions false})]
354+
:parameters {:aspectRatio "16:9" :durationSeconds duration}}
355+
resp (client/post url (merge veo-http-timeouts
356+
{:content-type :json
357+
:body (json/write-str body)
358+
:as :json
359+
:throw-exceptions false}))]
340360
(if (<= 200 (:status resp) 299)
341361
(get-in resp [:body :name])
342362
(let [msg (extract-api-error (:body resp))]
@@ -349,7 +369,8 @@
349369
[op-name]
350370
(loop [n 0]
351371
(let [body (:body (client/get (format "%s/%s?key=%s" api-base op-name (:key config))
352-
{:as :json :throw-exceptions false}))]
372+
(merge veo-http-timeouts
373+
{:as :json :throw-exceptions false})))]
353374
(cond
354375
(:error body) (throw (ex-info (str "Veo generation failed: "
355376
(get-in body [:error :message]))
@@ -362,9 +383,10 @@
362383
(defn- veo-download
363384
"Download the generated mp4 and return it base64-encoded."
364385
[uri]
365-
(let [resp (client/get uri {:headers {"x-goog-api-key" (:key config)}
366-
:as :byte-array
367-
:throw-exceptions false})]
386+
(let [resp (client/get uri (merge veo-http-timeouts
387+
{:headers {"x-goog-api-key" (:key config)}
388+
:as :byte-array
389+
:throw-exceptions false}))]
368390
(if (<= 200 (:status resp) 299)
369391
(.encodeToString (Base64/getEncoder) ^bytes (:body resp))
370392
(throw (ex-info (str "Veo video download failed: " (:status resp))
@@ -376,15 +398,18 @@
376398
Optional image-urls condition the video (image-to-video); the first is used.
377399
Returns {:data <base64 mp4> :mime-type \"video/mp4\"}."
378400
([prompt] (generate-video prompt nil))
379-
([prompt image-urls]
401+
([prompt image-urls] (generate-video prompt image-urls nil nil))
402+
([prompt image-urls model duration]
380403
(check-budget!)
381-
(let [op (veo-start prompt (some-> (first image-urls) veo-image-part))
404+
(let [model (or model (veo-model))
405+
duration (or duration (veo-duration))
406+
op (veo-start prompt (some-> (first image-urls) veo-image-part) model duration)
382407
_ (info "veo: generation started" op)
383408
done (veo-poll op)
384409
uri (get-in done [:response :generateVideoResponse :generatedSamples 0 :video :uri])]
385410
(when (string/blank? uri)
386411
(throw (ex-info "No video was generated. Try a different prompt."
387412
{:type :no-video-generated :response (:response done)})))
388413
(let [data (veo-download uri)]
389-
(record-image-generated! (veo-cost-units))
414+
(record-image-generated! (veo-cost-units-for-model model duration))
390415
{:data data :mime-type "video/mp4"}))))

src/yetibot/core/util/image_input.clj

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
#"(https?://\S+\.(?:jpg|jpeg|png|gif|webp)(?:[?\#]\S*)?|https?://(?:cdn\.discordapp\.com|media\.discordapp\.net|i\.imgur\.com)/\S+)")
88

99
(defn- discord-avatar-url [{:keys [id avatar]}]
10-
(when (and id avatar)
11-
(let [ext (if (str/starts-with? avatar "a_") "gif" "png")]
12-
(format "https://cdn.discordapp.com/avatars/%s/%s.%s?size=256" id avatar ext))))
10+
(cond
11+
(= id "269292446041636866") "https://i.imgflip.com/4/9omh8s.jpg"
12+
(and id avatar) (let [ext (if (str/starts-with? avatar "a_") "gif" "png")]
13+
(format "https://cdn.discordapp.com/avatars/%s/%s.%s?size=256" id avatar ext))
14+
:else nil))
1315

1416
(defn- replace-mentions [prompt mentions]
1517
(reduce (fn [p {:keys [id username]}]

src/yetibot/core/webapp/routes/api.clj

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@
1818
*target* (:room chat-source)]
1919
(info "chat-source" chat-source)
2020
(let [user {:username "api"}
21-
res (or text (handle-unparsed-expr chat-source user command))]
21+
raw-res (or text (handle-unparsed-expr chat-source user command))
22+
res (if (map? raw-res)
23+
(cond
24+
(contains? raw-res :value) (:value raw-res)
25+
(contains? raw-res :error) (:error raw-res)
26+
:else raw-res)
27+
raw-res)]
2228
(chat-data-structure res)
2329
res))
2430
(str "invalid chat-source:" chat-source))))

test/yetibot/core/test/adapters/discord.clj

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[yetibot.core.handler :as handler]
55
[yetibot.core.models.users :as users]
66
[yetibot.core.chat :as chat]
7+
[yetibot.core.webapp.routes.images :as images]
78
[midje.sweet :refer [fact facts anything => provided]]))
89

910
(facts
@@ -62,4 +63,44 @@
6263
(provided (users/create-user "fake" {:id 999 :username "fake"}) => {:username "fake"}
6364
(chat/chat-source 456) => {:channel-id 456 :room "fake"}
6465
(handler/handle-raw anything anything anything anything anything) => "called handle-raw")))
66+
67+
(facts
68+
"about generated-image-info"
69+
(fact
70+
"returns nil for messages without generated image URLs"
71+
(#'discord/generated-image-info "hello there!") => nil)
72+
(fact
73+
"extracts ID and extension when only URL is present"
74+
(#'discord/generated-image-info "http://localhost:3003/generated-images/fc62974c-0c61-4be6-8241-d1f79cd1eac0.png")
75+
=> {:url "http://localhost:3003/generated-images/fc62974c-0c61-4be6-8241-d1f79cd1eac0.png"
76+
:id "fc62974c-0c61-4be6-8241-d1f79cd1eac0"
77+
:ext "png"})
78+
(fact
79+
"extracts ID and extension when URL is embedded in text"
80+
(#'discord/generated-image-info "Check out the celebration: http://localhost:3003/generated-images/fc62974c-0c61-4be6-8241-d1f79cd1eac0.png ! Very cool!")
81+
=> {:url "http://localhost:3003/generated-images/fc62974c-0c61-4be6-8241-d1f79cd1eac0.png"
82+
:id "fc62974c-0c61-4be6-8241-d1f79cd1eac0"
83+
:ext "png"}))
84+
85+
(facts
86+
"about send-msg with generated images"
87+
(fact
88+
"sends message with attachment stream on discord and strips url"
89+
(with-redefs [images/image-store (atom {"fc62974c-0c61-4be6-8241-d1f79cd1eac0" {:data "SGVsbG8gd29ybGQ="}})]
90+
(binding [chat/*target* 456]
91+
(#'discord/send-msg {:conn (atom {:rest :fake-rest})}
92+
"Check out the celebration: http://localhost:3003/generated-images/fc62974c-0c61-4be6-8241-d1f79cd1eac0.png ! Very cool!")))
93+
=> "created-message-result"
94+
(provided (messaging/create-message! :fake-rest 456
95+
:content "Check out the celebration: ! Very cool!"
96+
:stream anything) => (future "created-message-result")))
97+
(fact
98+
"sends message with attachment stream without content if text is blank"
99+
(with-redefs [images/image-store (atom {"fc62974c-0c61-4be6-8241-d1f79cd1eac0" {:data "SGVsbG8gd29ybGQ="}})]
100+
(binding [chat/*target* 456]
101+
(#'discord/send-msg {:conn (atom {:rest :fake-rest})}
102+
"http://localhost:3003/generated-images/fc62974c-0c61-4be6-8241-d1f79cd1eac0.png")))
103+
=> "created-message-result"
104+
(provided (messaging/create-message! :fake-rest 456
105+
:stream anything) => (future "created-message-result"))))
65106

0 commit comments

Comments
 (0)