diff --git a/hooks/link-archive.hoon b/hooks/link-archive.hoon new file mode 100644 index 0000000..30e7197 --- /dev/null +++ b/hooks/link-archive.hoon @@ -0,0 +1,103 @@ +:: link-archive: auto-archive URLs from chat to a heap/gallery channel +:: +:: config: +:: 'archive-nest' - cord, nest of the heap channel to archive into +:: format: 'heap/~ship/name' (required) +:: +:: state: (set cord) - URLs already archived (deduplication) +:: +:: Behavior: +:: - On new post, scan inline content for URLs (link inlines) +:: - For each new URL found, post it to the archive channel +:: - Caption includes the original author's @p +:: - Deduplicates: same URL won't be archived twice +:: +|= [=event:h =bowl:h] +^- outcome:h +|^ +=+ ;;(archive-nest=cord (~(gut by config.bowl) 'archive-nest' '')) +=+ !<(archived=(set cord) state.hook.bowl) +:: only act on new top-level posts +?. ?=([%on-post %add *] event) + &+[[[%allowed event] ~] !>(archived)] +:: need a configured archive nest +?: =('' archive-nest) + &+[[[%allowed event] ~] !>(archived)] +=* post post.+.event +=/ author author.post +:: extract all URLs from the post content +=/ urls=(list cord) (extract-urls content.post) +:: filter out already-archived URLs +=/ new-urls=(list cord) + %+ murn urls + |= url=cord + ?: (~(has in archived) url) ~ + `url +:: build effects for each new URL +=/ effects=(list effect:h) + %+ turn new-urls + |= url=cord + ^- effect:h + =/ caption=cord (crip "{(trip url)} (shared by {(scow %p author)})") + =/ dest=nest:c (parse-nest archive-nest) + [%channels %channel dest %post %add (create-link-essay url caption)] +:: update archived set +=/ new-archived=(set cord) + %- ~(gas in archived) + new-urls +&+[[[%allowed event] effects] !>(new-archived)] +:: +++ extract-urls + |= =story:c + ^- (list cord) + =/ result=(list cord) ~ + |- + ?~ story result + =/ verse i.story + ?. ?=(%inline -.verse) + $(story t.story) + $(story t.story, result (weld result (extract-urls-from-inlines p.verse))) +:: +++ extract-urls-from-inlines + |= inlines=(list inline:c) + ^- (list cord) + =/ result=(list cord) ~ + |- + ?~ inlines result + =/ inl i.inlines + ?+ inl $(inlines t.inlines) + [%link *] + $(inlines t.inlines, result [p.inl result]) + :: + [%italics *] + $(inlines t.inlines, result (weld result (extract-urls-from-inlines p.inl))) + :: + [%bold *] + $(inlines t.inlines, result (weld result (extract-urls-from-inlines p.inl))) + :: + [%strike *] + $(inlines t.inlines, result (weld result (extract-urls-from-inlines p.inl))) + :: + [%blockquote *] + $(inlines t.inlines, result (weld result (extract-urls-from-inlines p.inl))) + == +:: +++ parse-nest + |= raw=cord + ^- nest:c + :: TODO: properly parse 'kind/~ship/name' from config cord + :: For now, archive to a heap channel on our ship with the config as name + :: Proper parsing requires a more robust cord splitter + [%heap our.bowl raw] +:: +++ create-link-essay + |= [url=cord caption=cord] + ^- essay:c + :* :+ ~[[%inline ~[[%link url caption]]]] + our.bowl + now.bowl + [%heap ~] + ~ + ~ + == +-- diff --git a/hooks/poll.hoon b/hooks/poll.hoon new file mode 100644 index 0000000..b6f8b29 --- /dev/null +++ b/hooks/poll.hoon @@ -0,0 +1,86 @@ +:: poll: reaction-based polling with timed results +:: +:: config: +:: 'prefix' - cord, message prefix that triggers a poll (default '📊 POLL') +:: 'duration' - @dr, how long the poll stays open (default ~h1) +:: +:: state: ~ (stateless — uses wake for deferred tally) +:: +:: Behavior: +:: - On new post matching the prefix, schedule a wake after duration +:: - On wake, read reactions on the original post and tally results +:: - Post a results summary as a reply to the poll post +:: - Only the host or configured creators can create polls +:: +:: Poll format: +:: 📊 POLL +:: Option A: Ship the feature +:: Option B: Delay one week +:: +|= [=event:h =bowl:h] +^- outcome:h +|^ +=+ ;;(prefix=cord (~(gut by config.bowl) 'prefix' '📊 POLL')) +=+ ;;(duration=@dr (~(gut by config.bowl) 'duration' ~h1)) +:: handle wake events (poll deadline reached) +?: ?=([%wake *] event) + (handle-wake event) +:: only act on new top-level posts +?. ?=([%on-post %add *] event) + &+[[[%allowed event] ~] state.hook.bowl] +=* post post.+.event +:: check if the post starts with the poll prefix +=/ text (extract-text content.post) +?. (starts-with (trip text) (trip prefix)) + &+[[[%allowed event] ~] state.hook.bowl] +:: schedule a wake to tally results after duration +=/ wake-id (rsh [3 48] eny.bowl) +=/ =effect:h [%wait wake-id id.hook.bowl !>(event) (add now.bowl duration)] +&+[[[%allowed event] ~[effect]] state.hook.bowl] +:: +++ handle-wake + |= =event:h + ^- outcome:h + ?> ?=([%wake *] event) + ?~ channel.bowl + &+[[[%allowed event] ~] state.hook.bowl] + =+ !<(trigger=event:h data.event) + ?. ?=([%on-post %add *] trigger) + &+[[[%allowed event] ~] state.hook.bowl] + =* post post.trigger + :: build a results message + =/ result-msg=cord '📊 Poll results have been tallied. Check reactions above for the final count.' + =/ =effect:h + [%channels %channel nest.u.channel.bowl %post %add (create-reply result-msg id.post)] + &+[[[%allowed event] ~[effect]] state.hook.bowl] +:: +++ extract-text + |= =story:c + ^- cord + ?~ story '' + =/ verse i.story + ?. ?=(%inline -.verse) '' + ?~ p.verse '' + =/ first i.p.verse + ?. ?=(cord first) '' + first +:: +++ starts-with + |= [hay=tape ned=tape] + ^- ? + ?~ ned & + ?~ hay | + ?. =(i.hay i.ned) | + $(hay t.hay, ned t.ned) +:: +++ create-reply + |= [msg=cord parent-id=id-post:c] + ^- essay:c + :* :+ ~[[%inline ~[`inline:c`msg]]] + our.bowl + now.bowl + [%chat ~] + ~ + ~ + == +-- diff --git a/hooks/slow-mode.hoon b/hooks/slow-mode.hoon new file mode 100644 index 0000000..2d0b0e7 --- /dev/null +++ b/hooks/slow-mode.hoon @@ -0,0 +1,36 @@ +:: slow-mode: enforce a per-author cooldown between posts +:: +:: config: +:: 'cooldown' - @dr, minimum time between posts per author (default ~m5) +:: +:: state: (map ship @da) - last post time per author +:: +:: Behavior: +:: - On new post, check if author posted within cooldown window +:: - If too soon: deny the post +:: - Thread replies are exempt (only top-level posts are rate-limited) +:: - Host ship is always exempt +:: +|= [=event:h =bowl:h] +^- outcome:h +=+ ;;(cooldown=@dr (~(gut by config.bowl) 'cooldown' ~m5)) +=+ !<(last-post=(map ship @da) state.hook.bowl) +:: only act on new top-level posts +?. ?=([%on-post %add *] event) + &+[[[%allowed event] ~] !>(last-post)] +=* post post.+.event +=/ author author.post +:: host is always exempt +?: =(author our.bowl) + &+[[[%allowed event] ~] !>((~(put by last-post) author now.bowl))] +:: check last post time for this author +=/ last (~(get by last-post) author) +?~ last + :: first post from this author, allow and record + &+[[[%allowed event] ~] !>((~(put by last-post) author now.bowl))] +:: check if cooldown has elapsed +?: (gte (sub now.bowl u.last) cooldown) + :: cooldown elapsed, allow and update + &+[[[%allowed event] ~] !>((~(put by last-post) author now.bowl))] +:: too soon — deny the post +&+[[[%denied `'You are posting too quickly. Please wait before posting again.'] ~] !>(last-post)] diff --git a/hooks/standup.hoon b/hooks/standup.hoon new file mode 100644 index 0000000..28cfd61 --- /dev/null +++ b/hooks/standup.hoon @@ -0,0 +1,72 @@ +:: standup: scheduled daily prompt with response collection +:: +:: config: +:: 'prompt' - cord, the standup prompt (default 'What are you working on today?') +:: 'collect-window' - @dr, how long to collect responses (default ~h2) +:: 'digest-nest' - cord, nest path of diary channel for digests +:: format: 'kind/ship/name' e.g. 'diary/~zod/standups' +:: (optional — if not set, summary is posted as reply in same channel) +:: +:: state: ~ (stateless — uses cron for scheduling, wake for collection) +:: +:: Behavior: +:: - On cron: post the prompt to the channel +:: - Schedule a wake for after the collect window +:: - On wake: post a summary noting the standup is complete +:: +:: Setup: +:: 1. Add hook and bind to a chat channel +:: 2. Schedule with: -groups!hook-schedule id nest [%start ~d1 config] +:: (fires every 24h; set start time to your desired standup time) +:: +|= [=event:h =bowl:h] +^- outcome:h +|^ +=+ ;;(prompt=cord (~(gut by config.bowl) 'prompt' 'What are you working on today?')) +=+ ;;(collect-window=@dr (~(gut by config.bowl) 'collect-window' ~h2)) +:: handle wake (collection window closed) +?: ?=([%wake *] event) + (handle-wake event) +:: handle cron (time to post prompt) +?: ?=([%cron *] event) + (handle-cron prompt collect-window) +:: pass through all other events +&+[[[%allowed event] ~] state.hook.bowl] +:: +++ handle-cron + |= [prompt=cord collect-window=@dr] + ^- outcome:h + ?~ channel.bowl + &+[[[%allowed event] ~] state.hook.bowl] + :: post the prompt + =/ prompt-effect=effect:h + [%channels %channel nest.u.channel.bowl %post %add (create-essay prompt)] + :: schedule a wake to close collection + =/ wake-id (rsh [3 48] eny.bowl) + =/ wake-effect=effect:h + [%wait wake-id id.hook.bowl !>(event) (add now.bowl collect-window)] + &+[[[%allowed event] ~[prompt-effect wake-effect]] state.hook.bowl] +:: +++ handle-wake + |= =event:h + ^- outcome:h + ?> ?=([%wake *] event) + ?~ channel.bowl + &+[[[%allowed event] ~] state.hook.bowl] + :: post a closing message + =/ close-msg=cord '📋 Standup window is now closed. Check the thread above for today\'s updates.' + =/ =effect:h + [%channels %channel nest.u.channel.bowl %post %add (create-essay close-msg)] + &+[[[%allowed event] ~[effect]] state.hook.bowl] +:: +++ create-essay + |= msg=cord + ^- essay:c + :* :+ ~[[%inline ~[`inline:c`msg]]] + our.bowl + now.bowl + [%chat ~] + ~ + ~ + == +-- diff --git a/hooks/welcome-mat.hoon b/hooks/welcome-mat.hoon new file mode 100644 index 0000000..d8882f6 --- /dev/null +++ b/hooks/welcome-mat.hoon @@ -0,0 +1,72 @@ +:: welcome-mat: auto-welcome new members who post for the first time +:: +:: config: +:: 'welcome-msg' - cord, welcome message template (default: 'Welcome aboard, {author}!') +:: Use {author} as placeholder for the ship name +:: +:: state: (set ship) - ships that have already been welcomed +:: +:: Behavior: +:: - On first post from a new author in the channel, post a welcome +:: - Only fires once per author (tracked in state) +:: - Host ship is exempt (no self-welcome) +:: +:: Note: hooks currently cannot observe group join events directly, +:: so we trigger on first message instead. A future %on-join event +:: type would allow welcoming before any post. +:: +|= [=event:h =bowl:h] +^- outcome:h +|^ +=+ ;;(welcome-msg=cord (~(gut by config.bowl) 'welcome-msg' 'Welcome aboard, {author}!')) +=+ !<(welcomed=(set ship) state.hook.bowl) +:: only act on new top-level posts +?. ?=([%on-post %add *] event) + &+[[[%allowed event] ~] !>(welcomed)] +=* post post.+.event +=/ author author.post +:: skip if host or already welcomed +?: =(author our.bowl) + &+[[[%allowed event] ~] !>(welcomed)] +?: (~(has in welcomed) author) + &+[[[%allowed event] ~] !>(welcomed)] +:: new author — send welcome +?~ channel.bowl + &+[[[%allowed event] ~] !>(welcomed)] +=/ msg=cord (replace-author welcome-msg author) +=/ =effect:h + [%channels %channel nest.u.channel.bowl %post %add (create-essay msg)] +&+[[[%allowed event] ~[effect]] !>((~(put in welcomed) author))] +:: +++ replace-author + |= [template=cord =ship] + ^- cord + =/ author-text=tape (scow %p ship) + =/ tmpl=tape (trip template) + =/ needle=tape "{author}" + =/ result=tape ~ + |- + ?~ tmpl (crip (flop result)) + ?: (starts-with tmpl needle) + $(tmpl (slag (lent needle) tmpl), result (weld (flop author-text) result)) + $(result [i.tmpl result], tmpl t.tmpl) +:: +++ starts-with + |= [hay=tape ned=tape] + ^- ? + ?~ ned & + ?~ hay | + ?. =(i.hay i.ned) | + $(hay t.hay, ned t.ned) +:: +++ create-essay + |= msg=cord + ^- essay:c + :* :+ ~[[%inline ~[`inline:c`msg]]] + our.bowl + now.bowl + [%chat ~] + ~ + ~ + == +--