freddie 0.0.124 → 0.0.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freddie",
3
- "version": "0.0.124",
3
+ "version": "0.0.126",
4
4
  "type": "module",
5
5
  "description": "Open JS agent harness built on pi-mono, floosie, xstate, and anentrypoint-design",
6
6
  "bin": {
@@ -5,6 +5,17 @@ import WebSocket from 'ws'
5
5
  const OP = { DISPATCH: 0, HEARTBEAT: 1, IDENTIFY: 2, RESUME: 6, RECONNECT: 7, INVALID_SESSION: 9, HELLO: 10, HEARTBEAT_ACK: 11 }
6
6
  // GUILD_MESSAGES(1<<9) + DIRECT_MESSAGES(1<<12) + MESSAGE_CONTENT(1<<15)
7
7
  const DEFAULT_INTENTS = (1 << 9) | (1 << 12) | (1 << 15)
8
+ // Discord CDN attachment URLs are directly fetchable with no auth, but with no
9
+ // auth also comes no trust in the advertised size -- guard against buffering
10
+ // something unreasonably large into memory.
11
+ const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
12
+ const ATTACHMENT_FETCH_TIMEOUT_MS = 10000
13
+
14
+ function contentTypeCategory(contentType) {
15
+ const t = (contentType || '').split('/')[0]
16
+ if (t === 'image' || t === 'audio' || t === 'video') return t
17
+ return 'other'
18
+ }
8
19
 
9
20
  export class DiscordAdapter extends EventEmitter {
10
21
  constructor(opts = {}) {
@@ -66,7 +77,42 @@ export class DiscordAdapter extends EventEmitter {
66
77
  if (p.t === 'MESSAGE_CREATE') {
67
78
  const m = p.d
68
79
  if (m.author?.bot) return // ignore bots and our own messages
69
- this.emit('message', { from: m.author?.id, text: m.content || '', raw: m, platform: 'discord' })
80
+ const base = { from: m.author?.id, text: m.content || '', raw: m, platform: 'discord' }
81
+ if (!m.attachments?.length) { this.emit('message', base); return }
82
+ // ws.on('message', ...) below is a sync callback and can't await this,
83
+ // so the fetch-and-emit path runs as a detached async task: the
84
+ // message still emits exactly once, after attachment fetches settle,
85
+ // and one attachment's failure (via allSettled) never blocks the
86
+ // others or drops the message itself.
87
+ this._resolveAttachments(m.attachments).then(media => {
88
+ this.emit('message', { ...base, media })
89
+ })
90
+ return
91
+ }
92
+ }
93
+
94
+ async _resolveAttachments(attachments) {
95
+ const results = await Promise.allSettled(attachments.map(a => this._fetchAttachment(a)))
96
+ return results.filter(r => r.status === 'fulfilled' && r.value).map(r => r.value)
97
+ }
98
+
99
+ async _fetchAttachment(a) {
100
+ if (a.size > MAX_ATTACHMENT_BYTES) {
101
+ console.error(`DiscordAdapter: skipping attachment ${a.filename} (${a.size} bytes exceeds ${MAX_ATTACHMENT_BYTES} byte guard)`)
102
+ return null
103
+ }
104
+ const ac = new AbortController()
105
+ const timer = setTimeout(() => ac.abort(), ATTACHMENT_FETCH_TIMEOUT_MS)
106
+ try {
107
+ const res = await fetch(a.url, { signal: ac.signal })
108
+ if (!res.ok) throw new Error(`attachment fetch failed with status ${res.status}`)
109
+ const buffer = Buffer.from(await res.arrayBuffer())
110
+ return { type: contentTypeCategory(a.content_type), mimeType: a.content_type || '', buffer, filename: a.filename }
111
+ } catch (err) {
112
+ console.error(`DiscordAdapter: attachment fetch failed for ${a.filename}`, err)
113
+ return null
114
+ } finally {
115
+ clearTimeout(timer)
70
116
  }
71
117
  }
72
118
 
@@ -28,6 +28,29 @@ export class WhatsappAdapter extends EventEmitter {
28
28
  try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) } catch { return false }
29
29
  }
30
30
 
31
+ // WhatsApp Cloud API media download is a two-step handshake: the webhook
32
+ // payload only ever carries a media id, never a fetchable URL directly.
33
+ // Step 1 resolves that id to a short-lived signed URL (also bearer-authed);
34
+ // step 2 fetches the actual bytes from that URL, still with the same
35
+ // bearer token (Meta requires it on both hops). Each hop is bounded by an
36
+ // AbortController timeout so a slow/hung Meta response can never wedge the
37
+ // webhook handler indefinitely.
38
+ async _downloadMedia(mediaId, timeoutMs = 10000) {
39
+ const authHeader = { authorization: `Bearer ${this.token}` }
40
+ const withTimeout = async (url) => {
41
+ const ac = new AbortController()
42
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
43
+ try { return await fetch(url, { headers: authHeader, signal: ac.signal }) }
44
+ finally { clearTimeout(timer) }
45
+ }
46
+ const meta = await withTimeout(`${this.api}/${mediaId}`).then(r => r.json())
47
+ if (!meta?.url) throw new Error('WhatsappAdapter: media lookup returned no url: ' + JSON.stringify(meta))
48
+ const res = await withTimeout(meta.url)
49
+ if (!res.ok) throw new Error(`WhatsappAdapter: media fetch failed with status ${res.status}`)
50
+ const buffer = Buffer.from(await res.arrayBuffer())
51
+ return { buffer, mimeType: meta.mime_type || res.headers.get('content-type') || '' }
52
+ }
53
+
31
54
  async start() {
32
55
  if (!this.token || !this.phoneId) throw new Error('WhatsappAdapter: WHATSAPP_API_TOKEN + WHATSAPP_PHONE_NUMBER_ID required')
33
56
  const app = express()
@@ -38,19 +61,34 @@ export class WhatsappAdapter extends EventEmitter {
38
61
  if (req.query['hub.verify_token'] === this.verifyToken) return res.send(req.query['hub.challenge'])
39
62
  res.sendStatus(403)
40
63
  })
41
- app.post(this.path, (req, res) => {
64
+ app.post(this.path, async (req, res) => {
42
65
  if (!this._verifySignature(req)) return res.sendStatus(401)
43
66
  const entries = req.body?.entry || []
44
67
  for (const e of entries) for (const c of (e.changes || [])) {
45
68
  const msgs = c.value?.messages || []
46
69
  for (const m of msgs) {
47
- this.emit('message', {
70
+ const event = {
48
71
  from: m.from,
49
72
  text: m.text?.body || '',
50
73
  // surface the platform message id for dedup, and the message type
51
74
  // so media-only messages are recognisable upstream.
52
75
  raw: { ...m, id: m.id, type: m.type },
53
- })
76
+ }
77
+ const mediaObj = m.image || m.audio || m.document || m.video
78
+ if (mediaObj?.id) {
79
+ const type = m.image ? 'image' : m.audio ? 'audio' : m.document ? 'document' : 'video'
80
+ try {
81
+ const { buffer, mimeType } = await this._downloadMedia(mediaObj.id)
82
+ event.media = { type, mimeType, buffer }
83
+ } catch (err) {
84
+ // Never let a failed/slow media fetch block the rest of the
85
+ // webhook batch or the ack below -- note media as
86
+ // present-but-unfetched so the pipeline still proceeds.
87
+ console.error('WhatsappAdapter: media download failed', err)
88
+ event.media = { type, mimeType: mediaObj.mime_type || '', buffer: null, error: String(err?.message || err) }
89
+ }
90
+ }
91
+ this.emit('message', event)
54
92
  }
55
93
  }
56
94
  res.json({ ok: true })