11import fs from "node:fs/promises" ;
22import path from "node:path" ;
33
4- import { confirm , multiselect , note , text } from "@clack/prompts" ;
4+ import { confirm , multiselect , note , select , text } from "@clack/prompts" ;
55import chalk from "chalk" ;
66
77import type { ClawdisConfig } from "../config/config.js" ;
88import { loginWeb } from "../provider-web.js" ;
99import type { RuntimeEnv } from "../runtime.js" ;
10+ import { normalizeE164 } from "../utils.js" ;
1011import { resolveWebAuthDir } from "../web/session.js" ;
1112import { detectBinary , guardCancel } from "./onboard-helpers.js" ;
1213import type { ProviderChoice } from "./onboard-types.js" ;
@@ -33,6 +34,7 @@ function noteProviderPrimer(): void {
3334 "Telegram: Bot API (token from @BotFather), replies via your bot." ,
3435 "Discord: Bot token from Discord Developer Portal; invite bot to your server." ,
3536 "Signal: signal-cli as a linked device (recommended: separate bot number)." ,
37+ "iMessage: local imsg CLI (JSON-RPC over stdio) reading Messages DB." ,
3638 ] . join ( "\n" ) ,
3739 "How providers work" ,
3840 ) ;
@@ -79,6 +81,11 @@ export async function setupProviders(
7981 ) ;
8082 const signalCliPath = cfg . signal ?. cliPath ?? "signal-cli" ;
8183 const signalCliDetected = await detectBinary ( signalCliPath ) ;
84+ const imessageConfigured = Boolean (
85+ cfg . imessage ?. cliPath || cfg . imessage ?. dbPath || cfg . imessage ?. allowFrom ,
86+ ) ;
87+ const imessageCliPath = cfg . imessage ?. cliPath ?? "imsg" ;
88+ const imessageCliDetected = await detectBinary ( imessageCliPath ) ;
8289
8390 note (
8491 [
@@ -100,9 +107,17 @@ export async function setupProviders(
100107 ? chalk . green ( "configured" )
101108 : chalk . yellow ( "needs setup" )
102109 } `,
110+ `iMessage: ${
111+ imessageConfigured
112+ ? chalk . green ( "configured" )
113+ : chalk . yellow ( "needs setup" )
114+ } `,
103115 `signal-cli: ${
104116 signalCliDetected ? chalk . green ( "found" ) : chalk . red ( "missing" )
105117 } (${ signalCliPath } )`,
118+ `imsg: ${
119+ imessageCliDetected ? chalk . green ( "found" ) : chalk . red ( "missing" )
120+ } (${ imessageCliPath } )`,
106121 ] . join ( "\n" ) ,
107122 "Provider status" ,
108123 ) ;
@@ -142,6 +157,11 @@ export async function setupProviders(
142157 label : "Signal (signal-cli)" ,
143158 hint : signalCliDetected ? "signal-cli found" : "signal-cli missing" ,
144159 } ,
160+ {
161+ value : "imessage" ,
162+ label : "iMessage (imsg)" ,
163+ hint : imessageCliDetected ? "imsg found" : "imsg missing" ,
164+ } ,
145165 ] ,
146166 } ) ,
147167 runtime ,
@@ -177,6 +197,71 @@ export async function setupProviders(
177197 } else if ( ! whatsappLinked ) {
178198 note ( "Run `clawdis login` later to link WhatsApp." , "WhatsApp" ) ;
179199 }
200+
201+ const existingAllowFrom = cfg . routing ?. allowFrom ?? [ ] ;
202+ if ( existingAllowFrom . length === 0 ) {
203+ note (
204+ [
205+ "WhatsApp direct chats are gated by `routing.allowFrom`." ,
206+ 'Default (unset) = self-chat only; use "*" to allow anyone.' ,
207+ ] . join ( "\n" ) ,
208+ "Allowlist (recommended)" ,
209+ ) ;
210+ const mode = guardCancel (
211+ await select ( {
212+ message : "Who can trigger the bot via WhatsApp?" ,
213+ options : [
214+ { value : "self" , label : "Self-chat only (default)" } ,
215+ { value : "list" , label : "Specific numbers (recommended)" } ,
216+ { value : "any" , label : "Anyone (*)" } ,
217+ ] ,
218+ } ) ,
219+ runtime ,
220+ ) as "self" | "list" | "any" ;
221+
222+ if ( mode === "any" ) {
223+ next = {
224+ ...next ,
225+ routing : { ...next . routing , allowFrom : [ "*" ] } ,
226+ } ;
227+ } else if ( mode === "list" ) {
228+ const allowRaw = guardCancel (
229+ await text ( {
230+ message : "Allowed sender numbers (comma-separated, E.164)" ,
231+ placeholder : "+15555550123, +447700900123" ,
232+ validate : ( value ) => {
233+ const raw = String ( value ?? "" ) . trim ( ) ;
234+ if ( ! raw ) return "Required" ;
235+ const parts = raw
236+ . split ( / [ \n , ; ] + / g)
237+ . map ( ( p ) => p . trim ( ) )
238+ . filter ( Boolean ) ;
239+ if ( parts . length === 0 ) return "Required" ;
240+ for ( const part of parts ) {
241+ if ( part === "*" ) continue ;
242+ const normalized = normalizeE164 ( part ) ;
243+ if ( ! normalized ) return `Invalid number: ${ part } ` ;
244+ }
245+ return undefined ;
246+ } ,
247+ } ) ,
248+ runtime ,
249+ ) ;
250+
251+ const parts = String ( allowRaw )
252+ . split ( / [ \n , ; ] + / g)
253+ . map ( ( p ) => p . trim ( ) )
254+ . filter ( Boolean ) ;
255+ const normalized = parts . map ( ( part ) =>
256+ part === "*" ? "*" : normalizeE164 ( part ) ,
257+ ) ;
258+ const unique = [ ...new Set ( normalized . filter ( Boolean ) ) ] ;
259+ next = {
260+ ...next ,
261+ routing : { ...next . routing , allowFrom : unique } ,
262+ } ;
263+ }
264+ }
180265 }
181266
182267 if ( selection . includes ( "telegram" ) ) {
@@ -395,6 +480,44 @@ export async function setupProviders(
395480 ) ;
396481 }
397482
483+ if ( selection . includes ( "imessage" ) ) {
484+ let resolvedCliPath = imessageCliPath ;
485+ if ( ! imessageCliDetected ) {
486+ const entered = guardCancel (
487+ await text ( {
488+ message : "imsg CLI path" ,
489+ initialValue : resolvedCliPath ,
490+ validate : ( value ) => ( value ?. trim ( ) ? undefined : "Required" ) ,
491+ } ) ,
492+ runtime ,
493+ ) ;
494+ resolvedCliPath = String ( entered ) . trim ( ) ;
495+ if ( ! resolvedCliPath ) {
496+ note ( "imsg CLI path required to enable iMessage." , "iMessage" ) ;
497+ }
498+ }
499+
500+ if ( resolvedCliPath ) {
501+ next = {
502+ ...next ,
503+ imessage : {
504+ ...next . imessage ,
505+ enabled : true ,
506+ cliPath : resolvedCliPath ,
507+ } ,
508+ } ;
509+ }
510+
511+ note (
512+ [
513+ "Ensure Clawdis has Full Disk Access to Messages DB." ,
514+ "Grant Automation permission for Messages when prompted." ,
515+ "List chats with: imsg chats --limit 20" ,
516+ ] . join ( "\n" ) ,
517+ "iMessage next steps" ,
518+ ) ;
519+ }
520+
398521 if ( options ?. allowDisable ) {
399522 if ( ! selection . includes ( "telegram" ) && telegramConfigured ) {
400523 const disable = guardCancel (
@@ -443,6 +566,22 @@ export async function setupProviders(
443566 } ;
444567 }
445568 }
569+
570+ if ( ! selection . includes ( "imessage" ) && imessageConfigured ) {
571+ const disable = guardCancel (
572+ await confirm ( {
573+ message : "Disable iMessage provider?" ,
574+ initialValue : false ,
575+ } ) ,
576+ runtime ,
577+ ) ;
578+ if ( disable ) {
579+ next = {
580+ ...next ,
581+ imessage : { ...next . imessage , enabled : false } ,
582+ } ;
583+ }
584+ }
446585 }
447586
448587 return next ;
0 commit comments