4
0

eggdrop-pisg.tcl 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. # eggdrop-pisg.tcl - pisg for eggdrop: !pisgstats, and profiles people edit themselves.
  2. #
  3. # Based on pisg.tcl by HM2K / Arganan (the !pisgstats command). The profile commands are new
  4. # in pisg 1.0.a. People set their own sex, picture, link and merge their nicks in chat; the
  5. # bot writes them to users.cfg, which pisg reads through <include="/path/to/users.cfg"> on
  6. # its next run. Nothing else is needed: no web page, no database.
  7. #
  8. # Commands (in the channel with !, or in a private message without it):
  9. # pisghelp [command] what the commands do
  10. # pisginfo <name> [male|female] [picture-url] [link] set your profile
  11. # pisgmerge <name> count the nick you are using now as <name>
  12. # pisgunmerge <nick> stop counting <nick> as yours
  13. # pisgshow [name] show a profile
  14. # pisgdel delete your own profile
  15. # pisgdeluser <name> bot masters only: delete anybody's profile
  16. # pisgstats give the address of the stats page (anybody)
  17. #
  18. # Who is who: the bot only edits a profile for someone it can identify, by the services account
  19. # (eggdrop's getaccount, or the account.users.undernet.org host X gives to people logged in
  20. # with +x). A profile belongs to the account, not the nick, so changing nick is fine. You can
  21. # only claim the nick you are using right now, so nobody can take somebody else's name.
  22. namespace eval pisg {
  23. variable version "1.0.a"
  24. # ---- settings -------------------------------------------------------------------------
  25. # Change these to match your setup, or keep your own values in pisg.local.tcl next to this file
  26. # (see below), so an update of this script never overwrites them.
  27. variable exe "/path/to/pisg/pisg" ;# the pisg script
  28. variable url "https://example.org/stats/" ;# folder the stats pages are in (with the closing /)
  29. variable chan "#yourchannel"
  30. variable outdir "/path/to/pisg/output" ;# where pisg writes the pages (for the "updated" age)
  31. variable adminflags "m" ;# who may use pisgdeluser
  32. variable usersfile "/path/to/pisg/users.cfg" ;# written by the bot, read by pisg
  33. variable maincfg "/path/to/pisg/pisg.cfg" ;# your hand-written <user> lines are protected
  34. variable trigger "!"
  35. variable accounthost {^[^@]*@([^.@]+)\.users\.undernet\.org$} ;# Undernet: the host X gives to a logged-in +x user. Other networks: see getaccount
  36. variable maxaliases 20
  37. variable minsecs 2 ;# seconds between two changes by the same account
  38. variable maxperhour 30 ;# changes per hour per account
  39. variable autorun 0 ;# 1: run pisg from a timer (cron normally does)
  40. variable autorunmin 180
  41. # Your own settings: a file called pisg.local.tcl in the same folder is read here, after the
  42. # defaults above. It only needs lines such as set ::pisg::url "https://example.org/stats/"
  43. set localsettings [file join [file dirname [info script]] pisg.local.tcl]
  44. if {[file exists $localsettings]} { source $localsettings }
  45. variable marker "# ---- managed by eggdrop-pisg.tcl: do not edit below this line, the bot rewrites it ----"
  46. variable hist; array set hist {}
  47. # ---- checks ---------------------------------------------------------------------------
  48. proc valid_nick {n} { return [regexp {^[A-Za-z_\[\]\\`^{|}][A-Za-z0-9_\-\[\]\\`^{|}]{0,29}$} $n] }
  49. # a web address that is safe to put in a page and in a config line
  50. proc valid_url {u} {
  51. if {[string length $u] > 300} { return 0 }
  52. if {![regexp {^https://([A-Za-z0-9.-]+)(:[0-9]+)?(/[^\s"'<>\\]*)?$} $u -> host]} { return 0 }
  53. # a real domain name: labels of letters, digits and inner hyphens, ending in a letters-only TLD.
  54. # That also rules out localhost, numeric addresses, ".org", "a..b" and "-x.com".
  55. if {![regexp {^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}$} $host]} { return 0 }
  56. return 1
  57. }
  58. proc valid_image {u} {
  59. return [expr {[valid_url $u] && [regexp -nocase {^[^?#]*\.(png|jpe?g|gif|webp)([?#].*)?$} $u]}]
  60. }
  61. proc valid_email {e} {
  62. return [regexp {^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,200}\.[A-Za-z]{2,}$} $e]
  63. }
  64. proc clean {s} { regsub -all {[\x00-\x1f\x7f]} $s " " s; return $s } ;# no control codes in what we send
  65. # ---- who is asking --------------------------------------------------------------------
  66. # The services account name of the person, or "" when they are not identified.
  67. proc account {nick uhost} {
  68. variable accounthost
  69. if {[llength [info commands getaccount]] && ![catch {getaccount $nick} a]} {
  70. if {$a ne "" && $a ne "*"} { return [string tolower $a] }
  71. }
  72. if {[regexp $accounthost $uhost -> acct]} { return [string tolower $acct] }
  73. return ""
  74. }
  75. proc login_help {} {
  76. return "I can only change a profile for someone I can identify. Log in to X and hide your host: /msg x@channels.undernet.org login <username> <password> then /mode <yournick> +x , then try again."
  77. }
  78. # ---- the users file -------------------------------------------------------------------
  79. # An entry is a dict: name aliases sex pic link owner.
  80. proc attr {line key} {
  81. if {[regexp "\\m$key=(\[\"'\])(.+?)\\1" $line -> q v]} { return $v }
  82. return ""
  83. }
  84. proc read_file {path} {
  85. if {![file exists $path]} { return "" }
  86. set f [open $path r]; fconfigure $f -encoding utf-8
  87. set d [read $f]; close $f
  88. return $d
  89. }
  90. # -> {manual-text entries}: the part above the marker is yours and is kept as it is
  91. proc load {} {
  92. variable usersfile; variable marker
  93. set data [read_file $usersfile]
  94. set i [string first $marker $data]
  95. if {$i < 0} { set manual $data; set managed "" } else {
  96. set manual [string range $data 0 [expr {$i - 1}]]
  97. set managed [string range $data [expr {$i + [string length $marker]}] end]
  98. }
  99. set entries {}
  100. foreach line [split $managed "\n"] {
  101. if {![string match "<user *" [string trim $line]]} { continue }
  102. set name [attr $line nick]
  103. if {$name eq ""} { continue }
  104. # aliases are split with regexp, never with list commands: nicks may contain { } [ ] \ which are special in Tcl lists
  105. lappend entries [dict create name $name aliases [regexp -all -inline {\S+} [attr $line alias]] sex [attr $line sex] \
  106. pic [attr $line pic] link [attr $line link] owner [attr $line owner]]
  107. }
  108. return [list $manual $entries]
  109. }
  110. proc save {manual entries} {
  111. variable usersfile; variable marker
  112. set out [string trimright $manual "\n"]
  113. if {$out eq ""} { set out "# pisg users. Lines above the marker are yours; the bot manages the ones below." }
  114. append out "\n\n$marker\n"
  115. foreach e [lsort -command {apply {{a b} {string compare -nocase [dict get $a name] [dict get $b name]}}} $entries] {
  116. set l "<user nick=\"[dict get $e name]\""
  117. if {[llength [dict get $e aliases]]} { append l " alias=\"[join [dict get $e aliases] { }]\"" }
  118. foreach {k a} {pic pic sex sex link link} {
  119. if {[dict get $e $k] ne ""} { append l " $a=\"[dict get $e $k]\"" }
  120. }
  121. append l " owner=\"[dict get $e owner]\">"
  122. append out "$l\n"
  123. }
  124. set tmp "$usersfile.tmp[pid]"
  125. set f [open $tmp w 0644]; fconfigure $f -encoding utf-8
  126. puts -nonewline $f $out; close $f
  127. file rename -force $tmp $usersfile ;# atomic: pisg never reads half a file
  128. }
  129. # every nick or alias somebody already holds: your hand-written lines, and everyone's entries
  130. proc reserved {manual entries} {
  131. variable maincfg
  132. set r [dict create]
  133. foreach text [list $manual [read_file $maincfg]] {
  134. foreach line [split $text "\n"] {
  135. set t [string trim $line]
  136. if {[string match "#*" $t] || ![string match "*<user*" $t]} { continue }
  137. set n [attr $line nick]
  138. if {$n ne ""} { dict set r [string tolower $n] manual }
  139. foreach a [regexp -all -inline {\S+} [attr $line alias]] { dict set r [string tolower $a] manual }
  140. }
  141. }
  142. foreach e $entries {
  143. dict set r [string tolower [dict get $e name]] [dict get $e owner]
  144. foreach a [dict get $e aliases] { dict set r [string tolower $a] [dict get $e owner] }
  145. }
  146. return $r
  147. }
  148. proc find_owner {entries acct} {
  149. set i 0
  150. foreach e $entries { if {[dict get $e owner] eq $acct} { return $i }; incr i }
  151. return -1
  152. }
  153. proc find_name {entries name} {
  154. set i 0
  155. foreach e $entries { if {[string equal -nocase [dict get $e name] $name]} { return $i }; incr i }
  156. return -1
  157. }
  158. proc sex_word {s} { switch $s { m {return male} f {return female} default {return "not set"} } }
  159. proc describe {e} {
  160. set s "[dict get $e name]: [sex_word [dict get $e sex]]"
  161. if {[dict get $e pic] ne ""} { append s ", picture [dict get $e pic]" }
  162. if {[dict get $e link] ne ""} { append s ", link [dict get $e link]" }
  163. if {[llength [dict get $e aliases]]} { append s ", also counted as: [join [dict get $e aliases] {, }]" }
  164. return $s
  165. }
  166. # ---- rate limit ------------------------------------------------------------------------
  167. proc allowed {acct} {
  168. variable hist; variable minsecs; variable maxperhour
  169. set now [clock seconds]
  170. set recent {}
  171. if {[info exists hist($acct)]} { foreach t $hist($acct) { if {$now - $t < 3600} { lappend recent $t } } }
  172. if {[llength $recent] && $now - [lindex $recent end] < $minsecs} { set hist($acct) $recent; return 0 }
  173. if {[llength $recent] >= $maxperhour} { set hist($acct) $recent; return 0 }
  174. lappend recent $now; set hist($acct) $recent
  175. return 1
  176. }
  177. # ---- the commands: each returns the lines to say back --------------------------------------
  178. proc parse_profile {words} {
  179. # -> {ok sex pic link error}
  180. set sex ""; set pic ""; set link ""
  181. foreach w $words {
  182. if {[regexp {^(sex|pic|link|mail)=(.*)$} $w -> k v]} {
  183. if {$k eq "sex"} { set w $v } elseif {$k eq "pic"} { set pic $v; continue } else { set link $v; continue }
  184. }
  185. switch -nocase -- $w {
  186. m - male { set sex m; continue }
  187. f - female { set sex f; continue }
  188. }
  189. if {[valid_image $w] && $pic eq ""} { set pic $w } elseif {[valid_url $w] && $link eq ""} { set link $w } \
  190. elseif {[valid_email $w] && $link eq ""} { set link $w } else {
  191. return [list 0 "" "" "" "I don't understand '[clean $w]': give male or female, a picture address (https, ending in .png .jpg .gif or .webp) and/or a link (https or an e-mail address)."]
  192. }
  193. }
  194. if {$pic ne "" && ![valid_image $pic]} { return [list 0 "" "" "" "That picture address is not valid: it must be https and end in .png, .jpg, .gif or .webp."] }
  195. if {$link ne "" && ![valid_url $link] && ![valid_email $link]} { return [list 0 "" "" "" "That link is not valid: use an https address or an e-mail address."] }
  196. return [list 1 $sex $pic $link ""]
  197. }
  198. proc cmd_info {nick acct isadmin arg} {
  199. set words [regexp -all -inline {\S+} $arg]
  200. if {[llength $words] < 1} { return [list "Usage: pisginfo <name> \[male|female\] \[picture-url\] \[link\] (ask: pisghelp pisginfo)"] }
  201. if {$acct eq ""} { return [list [login_help]] }
  202. set name [lindex $words 0]
  203. if {![valid_nick $name]} { return [list "'[clean $name]' is not a valid nick."] }
  204. lassign [parse_profile [lrange $words 1 end]] ok sex pic link err
  205. if {!$ok} { return [list $err] }
  206. if {$sex eq "" && $pic eq "" && $link eq ""} { return [list "Nothing to set. Give male or female, a picture and/or a link (ask: pisghelp pisginfo)."] }
  207. lassign [load] manual entries
  208. set i [find_owner $entries $acct]
  209. if {$i >= 0} {
  210. set e [lindex $entries $i]
  211. if {![string equal -nocase [dict get $e name] $name]} {
  212. return [list "You already have a profile named [dict get $e name]. Use that name, or delete it first with pisgdel."]
  213. }
  214. } else {
  215. if {![string equal -nocase $name $::pisg::currentnick]} {
  216. return [list "You can only create a profile for the nick you are using now ($::pisg::currentnick). Change to $name first, or use your current nick."]
  217. }
  218. set res [reserved $manual $entries]
  219. if {[dict exists $res [string tolower $name]]} {
  220. return [list "$name is already taken by another profile."]
  221. }
  222. set e [dict create name $name aliases "" sex "" pic "" link "" owner $acct]
  223. set entries [linsert $entries end $e]; set i [expr {[llength $entries] - 1}]
  224. }
  225. if {$sex ne ""} { dict set e sex $sex }
  226. if {$pic ne ""} { dict set e pic $pic }
  227. if {$link ne ""} { dict set e link $link }
  228. set entries [lreplace $entries $i $i $e]
  229. save $manual $entries
  230. return [list "Saved. [describe $e]. It shows on the next stats update."]
  231. }
  232. proc cmd_merge {nick acct isadmin arg} {
  233. set name [lindex [regexp -all -inline {\S+} $arg] 0]
  234. if {$name eq ""} { return [list "Usage: pisgmerge <name>: counts the nick you are using now ($nick) as <name>. (ask: pisghelp pisgmerge)"] }
  235. if {$acct eq ""} { return [list [login_help]] }
  236. if {![valid_nick $name]} { return [list "'[clean $name]' is not a valid nick."] }
  237. lassign [load] manual entries
  238. set i [find_owner $entries $acct]
  239. if {$i < 0 || ![string equal -nocase [dict get [lindex $entries $i] name] $name]} {
  240. return [list "You don't have a profile called $name. Create it first, from your main nick: pisginfo $name male|female"]
  241. }
  242. set e [lindex $entries $i]
  243. if {[string equal -nocase $nick $name]} { return [list "You are already $name."] }
  244. if {[lsearch -nocase [dict get $e aliases] $nick] >= 0} { return [list "$nick is already counted as $name."] }
  245. set res [reserved $manual $entries]
  246. if {[dict exists $res [string tolower $nick]]} { return [list "$nick is already used by another profile, so I can't add it."] }
  247. if {[llength [dict get $e aliases]] >= $::pisg::maxaliases} { return [list "That is the maximum of $::pisg::maxaliases aliases."] }
  248. dict lappend e aliases $nick
  249. set entries [lreplace $entries $i $i $e]
  250. save $manual $entries
  251. return [list "Done: $nick now counts as $name. It shows on the next stats update."]
  252. }
  253. proc cmd_unmerge {nick acct isadmin arg} {
  254. set a [lindex [regexp -all -inline {\S+} $arg] 0]
  255. if {$a eq ""} { return [list "Usage: pisgunmerge <nick>: stops counting <nick> as yours."] }
  256. if {$acct eq ""} { return [list [login_help]] }
  257. lassign [load] manual entries
  258. set i [find_owner $entries $acct]
  259. if {$i < 0} { return [list "You don't have a profile."] }
  260. set e [lindex $entries $i]
  261. set k [lsearch -nocase [dict get $e aliases] $a]
  262. if {$k < 0} { return [list "$a is not one of your aliases."] }
  263. dict set e aliases [lreplace [dict get $e aliases] $k $k]
  264. set entries [lreplace $entries $i $i $e]
  265. save $manual $entries
  266. return [list "Done: $a no longer counts as [dict get $e name]."]
  267. }
  268. proc cmd_show {nick acct isadmin arg} {
  269. set name [lindex [regexp -all -inline {\S+} $arg] 0]
  270. lassign [load] manual entries
  271. if {$name eq ""} {
  272. set i [expr {$acct ne "" ? [find_owner $entries $acct] : -1}]
  273. if {$i < 0} { set i [find_name $entries $nick] }
  274. if {$i < 0} { return [list "You have no profile yet. Make one with: pisginfo $nick male|female <picture-url> <link>"] }
  275. } else {
  276. set i [find_name $entries $name]
  277. if {$i < 0} {
  278. set res [reserved $manual {}]
  279. if {[dict exists $res [string tolower $name]]} { return [list "$name has a profile set by hand in the pisg config."] }
  280. return [list "No profile for [clean $name]."]
  281. }
  282. }
  283. return [list [describe [lindex $entries $i]]]
  284. }
  285. proc cmd_del {nick acct isadmin arg} {
  286. if {$acct eq ""} { return [list [login_help]] }
  287. lassign [load] manual entries
  288. set i [find_owner $entries $acct]
  289. if {$i < 0} { return [list "You don't have a profile."] }
  290. set name [dict get [lindex $entries $i] name]
  291. save $manual [lreplace $entries $i $i]
  292. return [list "Deleted the profile $name."]
  293. }
  294. proc cmd_deluser {nick acct isadmin arg} {
  295. if {!$isadmin} { return [list "Only bot masters can delete other people's profiles. To delete your own, use pisgdel."] }
  296. set name [lindex [regexp -all -inline {\S+} $arg] 0]
  297. if {$name eq ""} { return [list "Usage: pisgdeluser <name>"] }
  298. lassign [load] manual entries
  299. set i [find_name $entries $name]
  300. if {$i < 0} {
  301. set res [reserved $manual {}]
  302. if {[dict exists $res [string tolower $name]]} { return [list "$name is written by hand in a config file. Edit that file to remove it."] }
  303. return [list "No profile for [clean $name]."]
  304. }
  305. set d [lindex $entries $i]
  306. save $manual [lreplace $entries $i $i]
  307. return [list "Deleted the profile [dict get $d name] (account [dict get $d owner])."]
  308. }
  309. proc help_text {} {
  310. return {
  311. pisghelp {"pisghelp [command]: what the commands do. Try: pisghelp pisginfo"}
  312. pisginfo {"pisginfo <name> [male|female] [picture-url] [link]: set your profile on the stats page. <name> is the nick you are using now. Any of the parts can be given later, alone. Picture: https address ending in .png .jpg .gif or .webp. Link: an https address or an e-mail address."
  313. "Example: pisginfo Alice male https://example.org/me.png https://example.org"}
  314. pisgmerge {"pisgmerge <name>: counts the nick you are using now as <name>, so Alice_ and Alice- are the same person as Alice. Do it once from each nick, after creating <name> with pisginfo."}
  315. pisgunmerge {"pisgunmerge <nick>: stops counting <nick> as yours."}
  316. pisgshow {"pisgshow [name]: shows a profile. Without a name, shows yours."}
  317. pisgdel {"pisgdel: deletes your own profile, so your picture, link and merged nicks are removed from the stats."}
  318. pisgdeluser {"pisgdeluser <name>: bot masters only: deletes anybody's profile."}
  319. pisgstats {"pisgstats: regenerates the stats now and gives the address (friends of the bot only)."}
  320. }
  321. }
  322. proc cmd_help {nick acct isadmin arg} {
  323. variable trigger
  324. set h [help_text]
  325. set c [string tolower [string trimleft [lindex [regexp -all -inline {\S+} $arg] 0] $trigger]]
  326. if {$c ne "" && ![string match "pisg*" $c]} { set c "pisg$c" }
  327. if {$c eq ""} {
  328. return [list "Commands: pisginfo, pisgmerge, pisgunmerge, pisgshow, pisgdel, pisgdeluser, pisgstats. Use them in the channel with $trigger in front, or in a private message without it. For details: ${trigger}pisghelp <command>, e.g. ${trigger}pisghelp pisgshow." \
  329. "To change a profile you must be logged in to X with +x, so I can tell who you are."]
  330. }
  331. if {![dict exists $h $c]} { return [list "I don't know a command called '[clean $c]'. Try ${trigger}pisghelp."] }
  332. return [dict get $h $c]
  333. }
  334. # ---- eggdrop glue -----------------------------------------------------------------------
  335. variable currentnick ""
  336. proc say {nick lines} {
  337. foreach l $lines {
  338. set l [clean $l]
  339. while {[string length $l] > 380} { ;# IRC lines are limited to 512 bytes in all
  340. set cut [string last " " $l 380]; if {$cut < 100} { set cut 380 }
  341. putserv "NOTICE $nick :[string range $l 0 $cut]"
  342. set l [string trimleft [string range $l [expr {$cut + 1}] end]]
  343. }
  344. putserv "NOTICE $nick :$l"
  345. }
  346. }
  347. proc dispatch {cmd nick uhost hand arg} {
  348. variable currentnick
  349. set currentnick $nick
  350. set acct [account $nick $uhost]
  351. set edits {info merge unmerge del deluser}
  352. if {[lsearch $edits $cmd] >= 0 && $acct ne "" && ![allowed $acct]} {
  353. say $nick [list "Slow down a little, then try again."]; return
  354. }
  355. set isadmin [expr {$hand ne "*" && [matchattr $hand $::pisg::adminflags]}]
  356. if {[catch {cmd_$cmd $nick $acct $isadmin $arg} lines]} {
  357. putlog "pisg: error in $cmd for $nick: $lines"
  358. set lines [list "Something went wrong on my side, sorry. The bot owner can see it in the log."]
  359. } elseif {[lsearch $edits $cmd] >= 0 && [regexp {^(Saved|Done|Deleted)} [lindex $lines 0]]} {
  360. putlog "pisg: $cmd by $nick (account $acct): [lindex $lines 0]"
  361. }
  362. say $nick $lines
  363. }
  364. foreach c {help info merge unmerge show del deluser} {
  365. proc pub_$c {nick uhost hand chan arg} "dispatch $c \$nick \$uhost \$hand \$arg"
  366. proc msg_$c {nick uhost hand arg} "dispatch $c \$nick \$uhost \$hand \$arg"
  367. bind pub - "${trigger}pisg$c" ::pisg::pub_$c
  368. bind msg - "pisg$c" ::pisg::msg_$c
  369. }
  370. # The original command, kept: regenerate the stats now and give the link.
  371. # Open to everyone: it only points at the page the hourly cron job keeps fresh (it used to run
  372. # pisg inside the bot, which froze the bot for the length of the run and stayed silent
  373. # for anyone without the f flag).
  374. proc pub_stats {nick host hand chan arg} {
  375. variable url; variable outdir
  376. set page "[string tolower [string trimleft $chan #]].html"
  377. set age ""
  378. if {![catch {file mtime "$outdir/$page"} t]} {
  379. set m [expr {([clock seconds] - $t) / 60}]
  380. set age [expr {$m < 90 ? " (updated $m min ago)" : " (updated [expr {$m / 60}] h ago)"}]
  381. }
  382. puthelp "PRIVMSG $chan :Stats: ${url}${page}$age"
  383. }
  384. bind pub - "${trigger}pisgstats" ::pisg::pub_stats
  385. # Optional timer (off by default: the hourly cron job already runs pisg).
  386. proc timer_run {} {
  387. variable exe; variable autorunmin
  388. catch {exec $exe}
  389. timer $autorunmin ::pisg::timer_run
  390. }
  391. if {$autorun && ![info exists ::pisg_timer_set]} { set ::pisg_timer_set 1; timer 2 ::pisg::timer_run }
  392. }
  393. putlog "eggdrop-pisg.tcl $::pisg::version loaded (profiles: !pisghelp)"