@@ -40,6 +40,47 @@ function saveSeenIds(path, ids) {
4040 writeFileSync ( path , arr . join ( '\n' ) + '\n' ) ;
4141}
4242
43+ const DM_SEEN_FILE_DEFAULT = '/tmp/iak-dm-seen-ids.txt' ;
44+
45+ function normalizeHandle ( handle ) {
46+ if ( typeof handle !== 'string' ) return '' ;
47+ const trimmed = handle . trim ( ) ;
48+ if ( ! trimmed ) return '' ;
49+ return trimmed . startsWith ( '@' ) ? trimmed : `@${ trimmed } ` ;
50+ }
51+
52+ function parsePositiveInt ( value , fallback ) {
53+ const parsed = Number . parseInt ( value , 10 ) ;
54+ return Number . isFinite ( parsed ) && parsed > 0 ? parsed : fallback ;
55+ }
56+
57+ function appendNotifications ( path , lines ) {
58+ if ( lines . length === 0 ) return ;
59+ appendFileSync ( path , lines . join ( '\n' ) + '\n' ) ;
60+ }
61+
62+ function triggerNudge ( { nudgeMode, nudgeCommandText, nudgeText, session } ) {
63+ if ( nudgeMode === 'command' ) {
64+ return nudgeCommand ( nudgeCommandText , { text : nudgeText , session } ) ;
65+ }
66+ if ( nudgeMode === 'none' ) {
67+ return true ;
68+ }
69+ return nudgeTmux ( session , nudgeText ) ;
70+ }
71+
72+ export function isRelevantDirectMessage ( message , { selfHandle, humanOnly = false } = { } ) {
73+ if ( ! message || message . type !== 'dm' || ! message . id ) return false ;
74+ const sender = normalizeHandle ( message . from || message . sender ) ;
75+ const recipient = normalizeHandle ( message . to ) ;
76+ const expectedRecipient = normalizeHandle ( selfHandle ) ;
77+ if ( ! sender || ! recipient || ! expectedRecipient ) return false ;
78+ if ( recipient !== expectedRecipient ) return false ;
79+ if ( sender === expectedRecipient ) return false ;
80+ if ( humanOnly && ! message ?. metadata ?. user ) return false ;
81+ return true ;
82+ }
83+
4384function nudgeTmux ( session , text ) {
4485 try {
4586 execSync ( `tmux has-session -t ${ JSON . stringify ( session ) } 2>/dev/null` ) ;
@@ -71,6 +112,21 @@ async function fetchRoomMessages(room, apiKey, limit = 10) {
71112 }
72113}
73114
115+ async function fetchDirectMessages ( apiKey , limit = 100 ) {
116+ const url = `https://groupmind.one/api/v1/messages?limit=${ limit } ` ;
117+ try {
118+ const result = execSync (
119+ `curl -sS -4 -H "X-API-Key: ${ apiKey } " "${ url } "` ,
120+ { encoding : 'utf8' , timeout : 15000 }
121+ ) ;
122+ const data = JSON . parse ( result ) ;
123+ return data . messages || ( Array . isArray ( data ) ? data : [ ] ) ;
124+ } catch ( e ) {
125+ console . error ( ` fetch direct messages failed: ${ e . message } ` ) ;
126+ return [ ] ;
127+ }
128+ }
129+
74130/**
75131 * Read and clear the notification file. Returns array of message lines.
76132 * This is the primary way the IDE agent retrieves new messages.
@@ -96,8 +152,17 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config
96152 const nudgeText = config ?. tmux ?. nudge_text || 'check rooms' ;
97153 const nudgeMode = config ?. poller ?. nudge_mode || 'tmux' ;
98154 const nudgeCommandText = config ?. poller ?. nudge_command || '' ;
99- const pollInterval = interval || config ?. poller ?. interval_sec || 30 ;
100- const selfHandle = handle || config ?. poller ?. handle || '@unknown' ;
155+ const pollInterval = parsePositiveInt ( interval || config ?. poller ?. interval_sec , 30 ) ;
156+ const selfHandle = normalizeHandle ( handle || config ?. poller ?. handle || '@unknown' ) ;
157+ const dmCfg = config ?. dm_poller || { } ;
158+ const dmEnabled = dmCfg . enabled === true ;
159+ const dmHandle = normalizeHandle ( dmCfg . handle || selfHandle ) ;
160+ const dmSeenFile = dmCfg . seen_file || DM_SEEN_FILE_DEFAULT ;
161+ const dmNotifyFile = dmCfg . notification_file || notifyFile ;
162+ const dmPollInterval = parsePositiveInt ( dmCfg . interval_sec , pollInterval ) ;
163+ const dmApiKey = dmCfg . api_key || dmCfg . apiKey || config ?. poller ?. api_key || config ?. poller ?. apiKey || apiKey ;
164+ const dmHumanOnly = dmCfg . human_only === true ;
165+ const dmLimit = parsePositiveInt ( dmCfg . limit , 100 ) ;
101166
102167 console . log ( `Room poller started` ) ;
103168 console . log ( ` rooms: ${ rooms . join ( ', ' ) } ` ) ;
@@ -111,9 +176,19 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config
111176 console . log ( ` nudge command: ${ nudgeCommandText || '(missing)' } ` ) ;
112177 }
113178 console . log ( ` seen file: ${ seenFile } ` ) ;
179+ if ( dmEnabled ) {
180+ console . log ( ` direct messages: enabled` ) ;
181+ console . log ( ` dm handle: ${ dmHandle } ` ) ;
182+ console . log ( ` dm interval: ${ dmPollInterval } s` ) ;
183+ console . log ( ` dm seen file: ${ dmSeenFile } ` ) ;
184+ console . log ( ` dm notify file: ${ dmNotifyFile } ` ) ;
185+ console . log ( ` dm limit: ${ dmLimit } ` ) ;
186+ console . log ( ` dm human only: ${ dmHumanOnly } ` ) ;
187+ }
114188 console . log ( ` queue: ${ queuePath } ` ) ;
115189
116190 const seen = loadSeenIds ( seenFile ) ;
191+ const dmSeen = dmEnabled ? loadSeenIds ( dmSeenFile ) : new Set ( ) ;
117192
118193 // Seed: mark current messages as seen on first run
119194 if ( seen . size === 0 ) {
@@ -128,8 +203,25 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config
128203 console . log ( ` seeded ${ seen . size } IDs` ) ;
129204 }
130205
206+ if ( dmEnabled && dmSeen . size === 0 ) {
207+ console . log ( ` seeding seen IDs from current direct messages...` ) ;
208+ const directMessages = await fetchDirectMessages ( dmApiKey , dmLimit ) ;
209+ for ( const message of directMessages ) {
210+ if ( isRelevantDirectMessage ( message , { selfHandle : dmHandle , humanOnly : dmHumanOnly } ) ) {
211+ dmSeen . add ( message . id ) ;
212+ }
213+ }
214+ saveSeenIds ( dmSeenFile , dmSeen ) ;
215+ console . log ( ` seeded ${ dmSeen . size } DM IDs` ) ;
216+ }
217+
218+ let roomPollInFlight = false ;
219+ let dmPollInFlight = false ;
131220
132- async function poll ( ) {
221+ async function pollRooms ( ) {
222+ if ( roomPollInFlight ) return ;
223+ roomPollInFlight = true ;
224+ try {
133225 let newCount = 0 ;
134226 const newMessages = [ ] ;
135227 for ( const room of rooms ) {
@@ -140,8 +232,9 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config
140232 seen . add ( mid ) ;
141233
142234 const sender = m . from || m . sender || '?' ;
235+ const normalizedSender = normalizeHandle ( sender ) ;
143236 // Skip own messages
144- if ( sender === selfHandle || sender === selfHandle . replace ( '@' , '' ) ) continue ;
237+ if ( normalizedSender === selfHandle ) continue ;
145238
146239 const body = ( m . body || '' ) . slice ( 0 , 500 ) ;
147240 const ts = m . created_at || new Date ( ) . toISOString ( ) ;
@@ -176,37 +269,88 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config
176269
177270 if ( newCount > 0 ) {
178271 // Primary: write to notification file (always works)
179- appendFileSync ( notifyFile , newMessages . join ( '\n' ) + '\n' ) ;
180-
181- // Secondary: try configured nudge mode.
182- let nudged = false ;
183- if ( nudgeMode === 'command' ) {
184- nudged = nudgeCommand ( nudgeCommandText , { text : nudgeText , session } ) ;
185- } else if ( nudgeMode === 'none' ) {
186- nudged = true ;
187- } else {
188- nudged = nudgeTmux ( session , nudgeText ) ;
189- }
272+ appendNotifications ( notifyFile , newMessages ) ;
273+ const nudged = triggerNudge ( { nudgeMode, nudgeCommandText, nudgeText, session } ) ;
190274 console . log ( ` ${ newCount } new message(s) → notified${ nudged ? ' + nudge' : '' } ` ) ;
191275 }
276+ } finally {
277+ roomPollInFlight = false ;
278+ }
279+ }
280+
281+ async function pollDirectMessages ( ) {
282+ if ( ! dmEnabled || dmPollInFlight ) return ;
283+ dmPollInFlight = true ;
284+ try {
285+ let newCount = 0 ;
286+ const newMessages = [ ] ;
287+ const directMessages = await fetchDirectMessages ( dmApiKey , dmLimit ) ;
288+ for ( const message of directMessages ) {
289+ if ( ! isRelevantDirectMessage ( message , { selfHandle : dmHandle , humanOnly : dmHumanOnly } ) ) continue ;
290+ const mid = message . id ;
291+ if ( ! mid || dmSeen . has ( mid ) ) continue ;
292+ dmSeen . add ( mid ) ;
293+
294+ const sender = normalizeHandle ( message . from || message . sender ) || '?' ;
295+ const recipient = normalizeHandle ( message . to ) || dmHandle ;
296+ const body = ( message . body || '' ) . slice ( 0 , 500 ) ;
297+ const ts = message . created_at || new Date ( ) . toISOString ( ) ;
298+
299+ const rawEvent = {
300+ trace_id : randomUUID ( ) ,
301+ event_id : mid ,
302+ source : 'antfarm' ,
303+ kind : 'antfarm.dm.created' ,
304+ timestamp : ts ,
305+ room : null ,
306+ actor : { login : sender } ,
307+ payload : { body, type : 'dm' , to : recipient } ,
308+ intent : null ,
309+ memory_context : null ,
310+ enrichment_errors : [ ]
311+ } ;
312+ const event = await enrichEvent ( rawEvent , config ) ;
313+ appendFileSync ( queuePath , JSON . stringify ( event ) + '\n' ) ;
314+
315+ const line = `[${ ts . slice ( 0 , 19 ) } ] [dm] ${ sender } -> ${ recipient } : ${ body . replace ( / \n / g, ' ' ) . slice ( 0 , 200 ) } ` ;
316+ newMessages . push ( line ) ;
317+ newCount ++ ;
318+
319+ console . log ( ` [${ ts . slice ( 0 , 19 ) } ] ${ sender } DM -> ${ recipient } : ${ body . slice ( 0 , 80 ) } ...` ) ;
320+ }
321+
322+ saveSeenIds ( dmSeenFile , dmSeen ) ;
323+
324+ if ( newCount > 0 ) {
325+ appendNotifications ( dmNotifyFile , newMessages ) ;
326+ const nudged = triggerNudge ( { nudgeMode, nudgeCommandText, nudgeText, session } ) ;
327+ console . log ( ` ${ newCount } new direct message(s) → notified${ nudged ? ' + nudge' : '' } ` ) ;
328+ }
329+ } finally {
330+ dmPollInFlight = false ;
331+ }
192332 }
193333
194334 // Initial poll
195- await poll ( ) ;
335+ await pollRooms ( ) ;
336+ await pollDirectMessages ( ) ;
196337
197338 // Start interval
198- const timer = setInterval ( poll , pollInterval * 1000 ) ;
339+ const roomTimer = setInterval ( pollRooms , pollInterval * 1000 ) ;
340+ const dmTimer = dmEnabled ? setInterval ( pollDirectMessages , dmPollInterval * 1000 ) : null ;
199341
200342 // Handle shutdown
201343 process . on ( 'SIGINT' , ( ) => {
202344 console . log ( '\nPoller stopped.' ) ;
203- clearInterval ( timer ) ;
345+ clearInterval ( roomTimer ) ;
346+ if ( dmTimer ) clearInterval ( dmTimer ) ;
204347 process . exit ( 0 ) ;
205348 } ) ;
206349 process . on ( 'SIGTERM' , ( ) => {
207- clearInterval ( timer ) ;
350+ clearInterval ( roomTimer ) ;
351+ if ( dmTimer ) clearInterval ( dmTimer ) ;
208352 process . exit ( 0 ) ;
209353 } ) ;
210354
211- return timer ;
355+ return { roomTimer , dmTimer } ;
212356}
0 commit comments