Something went wrong. Try again.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511import { Hono } from "hono";import { WebClient } from "@slack/web-api";import { getAuth } from "./lib/auth";import { Feed } from "feed";import { OpenAPIHandler } from "@orpc/openapi/fetch";import { OpenAPIGenerator } from "@orpc/openapi";import { RPCHandler } from "@orpc/server/fetch";import { ZodSmartCoercionPlugin } from "@orpc/zod";import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";import { router } from "./api/router";import type { ORPCContext } from "./api/context";import { createApiKeyContext } from "./lib/api-keys";import { getDb } from "./db";import * as schema from "./db/schema";import { eq } from "drizzle-orm";import * as q from "./db/queries";import type { Channel as StoreChannel, Message as StoreMessage, Subscription as StoreSubscription,} from "./db/queries";
// --- Helpers ---
function getDbUrl(env: Env): string { return env.HYPERDRIVE_BINDING?.connectionString || env.DATABASE_URL;}
async function syncChannelName( db: any, slack: WebClient, channel: StoreChannel,): Promise<void> { try { const info = await slack.conversations.info({ channel: channel.id }); const name = (info.channel as any)?.name; if (typeof name === "string" && name && name !== channel.name) { channel.name = name; await q.updateChannelName(db, channel.id, name); console.log(`Updated channel name in DB: ${channel.id} -> #${name}`); } } catch (err: any) { console.warn( `Unable to sync channel name for ${channel.id}:`, err?.data?.error || err?.message || err, ); }}
export function resolveSlackMrkdwn(text: string): string { if (!text) return text; return text .replace(/<!here>/g, "") .replace(/<!channel>/g, "") .replace(/<!everyone>/g, "") .replace(/<#([A-Z0-9]+)\|([^>]+)>/g, "#$2") .replace(/<#([A-Z0-9]+)>/g, (_, id) => `#${id}`) .replace(/<@([A-Z0-9]+)\|([^>]+)>/g, "@$2") .replace(/<@([A-Z0-9]+)>/g, (_, id) => `<@${id}>`) .replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, "$2") .replace(/<(https?:\/\/[^>]+)>/g, "$1") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">");}
export function extractMessageText(msg: any): string { if (msg.blocks && msg.blocks.length > 0) { const parts: string[] = []; for (const block of msg.blocks) { if (block.type === "section" && block.text?.text) { parts.push(block.text.text); } else if (block.type === "rich_text" && block.elements) { const extractRichText = (elements: any[]): string[] => { const richParts: string[] = []; for (const element of elements) { if (typeof element.text === "string" && element.text) { richParts.push(element.text); } else if (element.type === "emoji" && element.name) { richParts.push(`:${element.name}:`); } if (Array.isArray(element.elements)) { richParts.push(...extractRichText(element.elements)); } } return richParts; }; parts.push(...extractRichText(block.elements)); } else if (block.type === "context" && block.elements) { for (const el of block.elements) { if (el.type === "mrkdwn" || el.type === "plain_text") { parts.push(el.text); } } } } if (parts.length > 0) return parts.join("\n"); } if (msg.attachments && msg.attachments.length > 0) { const parts: string[] = []; for (const attachment of msg.attachments) { for (const value of [ attachment.pretext, attachment.title, attachment.text, ]) { if (typeof value === "string" && value) parts.push(value); } for (const field of attachment.fields || []) { if (field.title) parts.push(String(field.title)); if (field.value) parts.push(String(field.value)); } } if (parts.length > 0) return parts.join("\n"); } if (msg.message && typeof msg.message === "object") { const nestedText = extractMessageText(msg.message); if (nestedText) return nestedText; } return msg.text || "";}
export function extractOriginalAuthor(msg: any): { userId?: string; userName?: string;} { if (msg.originalUserId || msg.originalUserName) { return { userId: msg.originalUserId, userName: msg.originalUserName, }; }
const nested = msg.message || msg.original_message; if (nested?.user || nested?.username || nested?.user_name) { return { userId: nested.user, userName: nested.username || nested.user_name, }; }
const attachment = (msg.attachments || []).find( (item: any) => item.author_id || item.author_name, ); if (attachment) { return { userId: attachment.author_id, userName: attachment.author_name, }; }
return {};}
export function extractOriginalTimestamp(msg: any): string | undefined { const nested = msg.message || msg.original_message; const nestedTimestamp = nested?.ts || nested?.message_ts || nested?.timestamp; if (typeof nestedTimestamp === "string" && nestedTimestamp) { return nestedTimestamp; } const attachmentTimestamp = (msg.attachments || []).find( (item: any) => item.ts || item.timestamp, ); const timestamp = attachmentTimestamp?.ts || attachmentTimestamp?.timestamp; return typeof timestamp === "string" && timestamp ? timestamp : undefined;}
/** Text written by the person sharing a nested Slack message, excluding the original. */export function extractForwarderComment(msg: any): string { const nested = msg.message || msg.original_message; if (nested && typeof msg.text === "string") return msg.text; return typeof msg.rawText === "string" ? msg.rawText : msg.text || "";}
export function isBlankForwardMessage(msg: any): boolean { const nested = msg.message || msg.original_message; const attachmentAuthor = (msg.attachments || []).some( (item: any) => item.author_id || item.author_name, ); const isForward = Boolean(nested || attachmentAuthor || msg.originalUserId); return isForward && !extractForwarderComment(msg).trim();}
export function resolveMessageTimestamp( msg: any, fallbackTimestamp: string, existingTimestamp?: string,): string { const originalTimestamp = extractOriginalTimestamp(msg); if (isBlankForwardMessage(msg) || !extractMessageText(msg).trim()) { if (originalTimestamp) return slackTsToTime(originalTimestamp).toISOString(); if (existingTimestamp) return existingTimestamp; } return slackTsToTime(fallbackTimestamp).toISOString();}
export function resolveStoredAuthors( msg: any, forwarderId: string, forwarderName: string,): { userId: string; userName: string } { const originalAuthor = extractOriginalAuthor(msg); const isBlankForward = isBlankForwardMessage(msg); if ( isBlankForward && (originalAuthor.userId || originalAuthor.userName) ) { return { userId: originalAuthor.userId || "", userName: originalAuthor.userName || "", }; } return { userId: q.mergeAuthorLists(forwarderId, originalAuthor.userId), userName: q.mergeAuthorLists(forwarderName, originalAuthor.userName), };}
function logMessageContentProbe(stage: string, msg: any): void { const extractedText = msg.rawText ?? extractMessageText(msg); const originalAuthor = extractOriginalAuthor(msg); console.log( `[message-content:${stage}]`, JSON.stringify({ channelId: msg.channelId || msg.channel, slackTs: msg.slackTs || msg.ts, subtype: msg.subtype || null, text: typeof msg.text === "string" ? msg.text : null, textLength: typeof msg.text === "string" ? msg.text.length : 0, rawText: typeof msg.rawText === "string" ? msg.rawText : null, rawTextLength: typeof msg.rawText === "string" ? msg.rawText.length : null, extractedText, extractedTextLength: extractedText.length, originalAuthor, blocks: Array.isArray(msg.blocks) ? msg.blocks.map((block: any) => ({ type: block.type, text: block.text?.text || null, elementTypes: Array.isArray(block.elements) ? block.elements.map((element: any) => element.type) : [], })) : [], attachments: Array.isArray(msg.attachments) ? msg.attachments.map((attachment: any) => ({ title: attachment.title || null, text: attachment.text || null, pretext: attachment.pretext || null, fieldCount: Array.isArray(attachment.fields) ? attachment.fields.length : 0, })) : [], files: Array.isArray(msg.files) ? msg.files.map((file: any) => ({ id: file.id || null, name: file.name || null, title: file.title || null, mimetype: file.mimetype || null, hasPreview: Boolean(file.preview || file.plain_text), })) : [], }), );}
function slackTsToTime(ts: string): Date { const parts = ts.split("."); const sec = parseInt(parts[0] || "0") || 0; const nsec = parseInt(parts[1] || "0") || 0; return new Date(sec * 1000 + nsec / 1e6);}
async function isChannelManager( channelId: string, userId: string, slack: WebClient,): Promise<boolean> { // Check if user is the channel creator try { const conv = await slack.conversations.info({ channel: channelId }); if ((conv.channel as any)?.creator === userId) return true; } catch { }
// Check if user is a workspace admin or owner (channel managers in Slack) try { const user = await slack.users.info({ user: userId }); const u = user.user as any; if (u?.is_admin || u?.is_owner || u?.is_primary_owner) return true; } catch { }
return false;}
async function verifySignature( request: Request, body: string, signingSecret: string,): Promise<boolean> { const ts = request.headers.get("X-Slack-Request-Timestamp") || ""; const sig = request.headers.get("X-Slack-Signature") || ""; const baseString = `v0:${ts}:${body}`; const encoder = new TextEncoder(); try { const key = await crypto.subtle.importKey( "raw", encoder.encode(signingSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sigBytes = await crypto.subtle.sign( "HMAC", key, encoder.encode(baseString), ); const computed = "v0=" + Array.from(new Uint8Array(sigBytes)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); if (computed.length !== sig.length) return false; return crypto.subtle.timingSafeEqual( encoder.encode(computed), encoder.encode(sig), ); } catch { return false; }}
async function slackResponse(responseUrl: string, text: string) { try { await fetch(responseUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, response_type: "ephemeral", replace_original: false, }), }); } catch { }}
async function postPermalinkReply( slack: WebClient, channelId: string, messageTs: string,): Promise<void> { try { const permalink = await slack.chat.getPermalink({ channel: channelId, message_ts: messageTs, }); const url = (permalink as any)?.permalink || ""; if (!url) return; await slack.chat.postMessage({ channel: channelId, thread_ts: messageTs, text: url, unfurl_links: false, unfurl_media: false, } as any); } catch { }}
async function fireWebhook(ch: StoreChannel, msg: StoreMessage) { if (!ch.webhookUrl) return; try { await fetch(ch.webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ event: "message.approved", channel: { id: ch.id, name: ch.name }, message: { ts: msg.slackTs, user_id: msg.userId, user_name: msg.userName, text: msg.text, timestamp: msg.timestamp, metadata: msg.metadata ? JSON.parse(msg.metadata) : undefined, }, }), }); } catch { }}
async function uploadToCDN( slackUrl: string, botToken: string, cdnKey: string,): Promise<string> { if (!cdnKey) return slackUrl; const resp = await fetch(slackUrl, { headers: { Authorization: `Bearer ${botToken}` }, }); if (!resp.ok) return slackUrl; const blob = await resp.blob(); const formData = new FormData(); formData.append("file", blob, "upload"); const cdnResp = await fetch("https://cdn.hackclub.com/api/v4/upload", { method: "POST", headers: { Authorization: `Bearer ${cdnKey}` }, body: formData, }); if (!cdnResp.ok) { console.error("CDN upload failed:", await cdnResp.text()); return slackUrl; } const data = (await cdnResp.json()) as any; return data.url || slackUrl;}
// --- Forwarding helpers ---
export function isLinkOnly(text: string): boolean { const trimmed = text.trim(); return /^https?:\/\/\S+$/.test(trimmed);}
export function isMessageEmpty(text: string): boolean { return text.trim().length === 0;}
export function hasChannelPing(text: string): boolean { return /<!(?:here|channel|everyone)>/.test(text);}
export function hasRecentSubstantiveMessage( current: Pick<StoreMessage, "slackTs" | "text" | "rawText">, recent: Array<Pick<StoreMessage, "slackTs" | "text" | "rawText">>,): boolean { return recent.some((m) => { const text = m.rawText ?? m.text; return ( m.slackTs !== current.slackTs && !isLinkOnly(text) && !isMessageEmpty(text) && !hasChannelPing(text) ); });}
export async function shouldForward( msg: StoreMessage, db: any,): Promise<boolean> { const policyText = msg.rawText ?? msg.text; // Always forward Slack permalink cross-posts if (isSlackPermalink(policyText)) return true; // Always forward empty messages (Slack forwarded messages often come as empty) if (isMessageEmpty(policyText)) return true; if (isLinkOnly(policyText)) { console.log( `shouldForward: skipping link-only message ${msg.slackTs}: ${policyText.substring(0, 80)}`, ); return false; } if (hasChannelPing(policyText)) { const since = new Date(Date.now() - 5 * 60 * 1000); const recent = await q.getRecentMessages(db, msg.channelId, since); if (hasRecentSubstantiveMessage(msg, recent)) return false; } return true;}
export function isSlackPermalink(text: string): boolean { const trimmed = text.trim(); return ( /^https:\/\/hackclub\.slack\.com\/archives\/[A-Z0-9]+\/p\d+$/.test( trimmed, ) || /^https:\/\/[a-z0-9-]+\.slack\.com\/archives\/[A-Z0-9]+\/p\d+$/.test( trimmed, ) );}
// --- Feed access control ---
export interface FeedIdentity { type: "session" | "apikey"; slackId: string; channelIds?: string[];}
async function resolveFeedIdentity( c: any, env: Env,): Promise<FeedIdentity | null> { const dbUrl = getDbUrl(env);
// 1. Try session from cookie try { const auth = getAuth(dbUrl, env as any); const s = await auth.api.getSession({ headers: c.req.raw.headers }); if (s?.user && (s.user as any).slackId) { return { type: "session", slackId: (s.user as any).slackId }; } } catch { }
// 2. Try API key from header or ?api_key= param const authHeader = c.req.header("Authorization") || ""; const queryKey = c.req.query("api_key") || ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : queryKey; if (token && token.startsWith("ind_")) { try { const identity = await createApiKeyContext({ req: c.req.raw, databaseUrl: dbUrl, }); if (identity.owner) { const db = getDb(dbUrl); const users = await db .select({ slackId: schema.authUser.slackId }) .from(schema.authUser) .where(eq(schema.authUser.id, identity.owner)); if (users[0]?.slackId) { return { type: "apikey", slackId: users[0].slackId, channelIds: identity.channelIds, }; } } } catch { } }
return null;}
export function canAccessFeed( ch: StoreChannel, identity: FeedIdentity | null,): boolean { // API keys are scoped to specific channels if ( identity?.type === "apikey" && identity.channelIds && identity.channelIds.length > 0 ) { return identity.channelIds.includes(ch.id); } if (ch.accessPermUsers.includes("*")) return true; if (!identity) return false; return ch.accessPermUsers.includes(identity.slackId);}
export function canApproveMessage({ approvedPosters, clickingUser, messageUser, isManager, isLockdown,}: { approvedPosters: string[]; clickingUser: string; messageUser: string; isManager: boolean; isLockdown: boolean;}): boolean { const messageUsers = q.splitAuthorList(messageUser); if (isLockdown || isManager) return true; if (approvedPosters.includes("poster") && messageUsers.includes(clickingUser)) { return true; } return approvedPosters.includes(clickingUser);}
export type PublishDisposition = "auto" | "prompt" | "drop";
/** * Decide how a message should be handled for a channel with publish * whitelists. `identity` is the author's Slack user ID, or for a bot message * whose event carries no user field, its bot ID (or both, since either may be * whitelisted) - a match on any identity counts. If either whitelist (auto or * manual) is non-empty, only whitelisted identities are published: auto * identities are auto-published, manual identities get a Yep!/No prompt, and * everyone else is dropped. With no whitelist, everyone is prompted. */export function resolvePublishDisposition( ch: { autoApproveUsers: string[]; manualUsers: string[] }, identity: string | (string | undefined)[],): PublishDisposition { const ids = (Array.isArray(identity) ? identity : [identity]).filter( (id): id is string => !!id, ); const auto = ch.autoApproveUsers.filter(Boolean); if (auto.includes("*") || ids.some((id) => auto.includes(id))) return "auto"; const manual = ch.manualUsers.filter(Boolean); if (ids.some((id) => manual.includes(id))) return "prompt"; const anyWhitelist = auto.length > 0 || manual.length > 0; return anyWhitelist ? "drop" : "prompt";}
export type MessageAdmission = | { allowed: true; disposition: PublishDisposition } | { allowed: false; reason: string };
/** * Decide whether a Slack message event should be admitted into the publish * pipeline, and if so how it should be handled. `bot_message` events are only * admitted when the bot user is in a publish whitelist (auto or manual), and * events authored by the indigest bot itself are always rejected so an * `*` auto-approve list can never create a forward loop. */export function resolveMessageAdmission(opts: { subtype?: string; user?: string; botId?: string; selfUserId?: string; selfBotId?: string; autoApproveUsers: string[]; manualUsers: string[]; /** "shortcut" channels never admit ambient messages; publishing only happens via the message shortcut. */ triggerMode: "message" | "shortcut";}): MessageAdmission { if (opts.triggerMode === "shortcut") { return { allowed: false, reason: "shortcut-only" }; } if (opts.selfUserId && opts.user === opts.selfUserId) { return { allowed: false, reason: "self-authored" }; } if (opts.selfBotId && opts.botId && opts.botId === opts.selfBotId) { return { allowed: false, reason: "self-authored" }; } if (opts.subtype && opts.subtype !== "bot_message") { return { allowed: false, reason: `unhandled subtype ${opts.subtype}` }; } if (opts.subtype === "bot_message") { // Bot events often carry only a bot_id and no user field, so a whitelist // entry may name either the bot's Slack user ID or its bot ID. const whitelisted = opts.autoApproveUsers.includes("*") || [opts.user, opts.botId].some( (id) => !!id && (opts.autoApproveUsers.includes(id) || opts.manualUsers.includes(id)), ); if (!whitelisted) { return { allowed: false, reason: "unwhitelisted bot" }; } } return { allowed: true, disposition: resolvePublishDisposition( { autoApproveUsers: opts.autoApproveUsers, manualUsers: opts.manualUsers }, [opts.user, opts.botId], ), };}
export function canEditBotMessage( userId: string, lockdownUsers: string[],): boolean { return lockdownUsers.includes(userId);}
export function isMetadataValueSet(value: unknown): boolean { return !( value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0) );}
export function hasProvidedMetadata(metadata: unknown): boolean { if (metadata === undefined || metadata === null || metadata === "") { return false; } const parsed = typeof metadata === "string" ? (() => { try { return JSON.parse(metadata); } catch { return null; } })() : metadata; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return false; } return Object.values(parsed).some(isMetadataValueSet);}
export function metadataRequirementSatisfied( metadataRequired: boolean, metadata: unknown,): boolean { return !metadataRequired || hasProvidedMetadata(metadata);}
/** * Resolve a sub channel's profile override string. A value wrapped in square * brackets (e.g. `[title]` or `[a.b]` / `[a].[b]`) is looked up in the source * channel's static `properties` map. Any other string is returned verbatim. * Returns `null` when there is no override or the referenced property is unset. * @param props - parsed source-channel static properties object */export function resolveSubProfileValue( value: string | undefined | null, props: Record<string, unknown> | null,): string | null { if (!value) return null; const trimmed = value.trim(); const bracketMatch = trimmed.match(/^\[(.+)\]$/); if (!bracketMatch) return value.trim(); if (!props || typeof props !== "object") return null; const keyPath = bracketMatch[1]!.replace(/\]\[/g, "."); const resolved = keyPath.split(".").reduce<unknown>( (acc, key) => (acc && typeof acc === "object" ? (acc as any)[key] : undefined), props, ); if (typeof resolved === "string") return resolved; if (Array.isArray(resolved)) return resolved.join(","); if (resolved === 0 || resolved === false) return String(resolved); return null;}
export function findExistingForward( actions: q.BotAction[], subscriberChannelId: string,): q.BotAction | undefined { return actions.find( (action) => action.type === "pub" && action.botChannelId === subscriberChannelId, );}
export function upsertMetadataBlock( blocks: any[], metadataSection: string,): any[] { const withoutExistingMetadata = blocks.filter( (block) => block?.block_id !== "indigest_metadata", ); const metadataBlock = { type: "section", block_id: "indigest_metadata", text: { type: "mrkdwn", text: metadataSection }, }; const contextIdx = withoutExistingMetadata.findIndex( (block) => block?.type === "context", ); if (contextIdx >= 0) { withoutExistingMetadata.splice(contextIdx, 0, metadataBlock); } else { withoutExistingMetadata.push(metadataBlock); } return withoutExistingMetadata;}
async function forwardToSubscribers( msg: StoreMessage, sourceChannel: StoreChannel, db: any, slack: WebClient,) { logMessageContentProbe("forward", msg); await syncChannelName(db, slack, sourceChannel); const subs = await q.getSubscribersBySource(db, msg.channelId); if (subs.length === 0) { console.log( `forwardToSubscribers: no subscribers for channel ${msg.channelId}`, ); return; } const existingPubActions = await q.getBotActionsBySource( db, msg.channelId, msg.slackTs, ); const hasExistingForward = existingPubActions.some( (action) => action.type === "pub", ); if ( !metadataRequirementSatisfied( sourceChannel.metadataRequired, msg.metadata, ) && !hasExistingForward ) { console.log( `forwardToSubscribers: metadata required for ${msg.slackTs}, skipping`, ); return; } if (!(await shouldForward(msg, db)) && !hasExistingForward) { console.log( `forwardToSubscribers: shouldForward returned false for ${msg.slackTs}`, ); return; }
let permalink = ""; try { const r = await slack.chat.getPermalink({ channel: msg.channelId, message_ts: msg.slackTs, }); permalink = (r as any)?.permalink || ""; } catch { } if (!permalink) { const slackTsClean = msg.slackTs.replace(".", ""); permalink = `https://slack.com/archives/${msg.channelId}/p${slackTsClean}`; } const msgDate = new Date(msg.timestamp); const dateStr = `${msgDate.getFullYear()}-${msgDate.getMonth() + 1}-${msgDate.getDate()}`;
const originalAuthor = extractOriginalAuthor(msg); let displayName = originalAuthor.userName || msg.userName || "unknown"; let avatarUrl = "";
const attributionUserId = originalAuthor.userId || (originalAuthor.userName ? "" : msg.userId); if (attributionUserId) { try { const u = await slack.users.info({ user: attributionUserId }); const user = u.user as any; const profile = user?.profile; displayName = user?.real_name || user?.name || profile?.display_name || originalAuthor.userName || msg.userName || "unknown"; avatarUrl = profile?.image_72 || profile?.image_original || profile?.image_48 || ""; } catch (err) { console.error(`Failed to fetch user info for ${attributionUserId}:`, err); } }
for (const sub of subs) { const existingForward = findExistingForward( existingPubActions, sub.subscriberChannelId, ); const subCh = await q.getChannel(db, sub.subscriberChannelId); if (!subCh) { console.warn( `Subscriber channel ${sub.subscriberChannelId} is not cached; attempting delivery by Slack channel ID`, ); } else { await syncChannelName(db, slack, subCh); }
// Get the link to forward from the message text or the computed permalink. let forwardLink = ""; let originalAuthorName = ""; let originalAuthorAvatar = ""; let originalText = "";
// Check if text is a Slack permalink if (isSlackPermalink(msg.text)) { forwardLink = msg.text.trim(); } if (!forwardLink) forwardLink = permalink;
// Link mode: don't fetch/repost the original text if (sourceChannel.linkMode) { originalText = ""; } // If we have a Slack permalink and we're not in link mode, try to fetch the original message details if ( !sourceChannel.linkMode && forwardLink && forwardLink.includes("slack.com/archives/") ) { const permalinkMatch = forwardLink.match( /\/archives\/([A-Z0-9]+)\/p(\d+)/, ); if (permalinkMatch) { const origChannel = permalinkMatch[1]; const origTs = permalinkMatch[2].slice(0, 10) + "." + permalinkMatch[2].slice(10); try { const history = await slack.conversations.history({ channel: origChannel, latest: origTs, limit: 1, inclusive: true, }); const origMsg = history.messages?.[0] as any; if (origMsg) { originalText = origMsg.text || ""; if (origMsg.user) { const u = await slack.users.info({ user: origMsg.user }); const user = u.user as any; const profile = user?.profile; originalAuthorName = user?.real_name || user?.name || profile?.display_name || origMsg.user; originalAuthorAvatar = profile?.image_72 || profile?.image_original || profile?.image_48 || ""; } } } catch { } } }
// Use original author details if available, otherwise use the forwarder const postUsername = originalAuthorName || displayName; const postIcon = originalAuthorAvatar || avatarUrl || undefined;
// Per-sub profile override: a sub channel can pin a static bot username / // pfp, or reference a static [property] on the source channel. Falls back // to the original author details above when unset or unresolved. let props: Record<string, unknown> | null = null; if (sourceChannel.properties && sourceChannel.properties !== "{}") { try { const parsed = JSON.parse(sourceChannel.properties); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { props = parsed; } } catch { props = null; } } const resolvedSubName = resolveSubProfileValue(sub.profileName, props); const resolvedSubIcon = resolveSubProfileValue(sub.profileImageUrl, props); const effectiveUsername = resolvedSubName ?? postUsername; const effectiveIcon = resolvedSubIcon ?? postIcon;
// Build the context footer // If this message has a forwardLink to another channel, show the full chain let footerText: string; if (forwardLink && !forwardLink.includes(msg.channelId)) { // This is a forward from another channel — extract the original source const fm = forwardLink.match(/\/archives\/([A-Z0-9]+)/); const origSourceChannel = fm ? fm[1] : ""; let origSourceName = origSourceChannel; if (origSourceChannel) { const origCh = await q.getChannel(db, origSourceChannel); if (origCh?.name) origSourceName = origCh.name; } footerText = `📰 Forwarded from <#${msg.channelId}|${sourceChannel.name}> from <#${origSourceChannel}|${origSourceName}> — <${permalink}|view source from ${dateStr}>`; } else { footerText = `📰 Forwarded from <#${msg.channelId}|${sourceChannel.name}> — <${permalink}|view original from ${dateStr}>`; } const contextElements = [{ type: "mrkdwn", text: footerText }];
const parsedMetadata = msg.metadata && msg.metadata != "" ? typeof msg.metadata === "string" ? JSON.parse(msg.metadata) : msg.metadata : null; const parsedSchema = sourceChannel.metadataSchema ? JSON.parse(sourceChannel.metadataSchema) : null; const metadataLines = parsedMetadata && parsedSchema ? Object.keys(parsedMetadata) .filter((metaProp) => isMetadataValueSet(parsedMetadata[metaProp])) .map((metaProp) => { const field = parsedSchema.fields?.find( (e: any) => e.action_id === metaProp, ); return `*${field?.label || metaProp}:* ${parsedMetadata[metaProp]}`; }) : []; const metadataSection = metadataLines.length ? metadataLines.join("\n") + "\n" : "";
console.log( "metadata:", parsedMetadata, "schema:", sourceChannel.metadataSchema, );
try { if (sourceChannel.linkMode) { const payload: any = { username: effectiveUsername, icon_url: effectiveIcon, unfurl_links: false, unfurl_media: false, text: metadataSection ? metadataSection.trim() + "\n" + footerText : footerText, blocks: [ ...(metadataSection ? [ { type: "section", block_id: "indigest_metadata", text: { type: "mrkdwn", text: metadataSection.trim() }, }, ] : []), { type: "context", elements: [{ type: "mrkdwn", text: footerText }] }, ], }; const res = existingForward ? await slack.chat.update({ channel: sub.subscriberChannelId, ts: existingForward.botMessageTs, text: payload.text, ...(payload.blocks ? { blocks: payload.blocks } : {}), } as any) : await slack.chat.postMessage({ channel: sub.subscriberChannelId, ...payload, } as any); if (!existingForward && res?.ts) { await q.addBotAction(db, { type: "pub", sourceChannelId: msg.channelId, sourceMessageTs: msg.slackTs, botChannelId: sub.subscriberChannelId, botMessageTs: res.ts, createdAt: "", }); } continue; }
const blocks: any[] = [ { type: "section", text: { type: "mrkdwn", text: (originalText ? originalText : forwardLink ? `<${forwardLink}|🔗 View forwarded message>` : msg.text || "_[no text]_" ).slice(0, 3000), }, }, ]; if (metadataSection) { blocks.push({ type: "section", block_id: "indigest_metadata", text: { type: "mrkdwn", text: metadataSection.trim() }, }); } blocks.push({ type: "context", elements: contextElements }); const payload: any = { username: effectiveUsername, icon_url: effectiveIcon, unfurl_links: false, unfurl_media: false, text: (originalText ? `📰 *${effectiveUsername}* in #${sourceChannel.name}:\n${originalText}` : forwardLink ? `📰 Forwarded from #${sourceChannel.name}\n${forwardLink}` : `📰 *${displayName}* in #${sourceChannel.name}:\n${msg.text}` ).slice(0, 3000), blocks, }; const res = existingForward ? await slack.chat.update({ channel: sub.subscriberChannelId, ts: existingForward.botMessageTs, text: payload.text, blocks: payload.blocks, } as any) : await slack.chat.postMessage({ channel: sub.subscriberChannelId, ...payload, }); if (!existingForward && res?.ts) { await q.addBotAction(db, { type: "pub", sourceChannelId: msg.channelId, sourceMessageTs: msg.slackTs, botChannelId: sub.subscriberChannelId, botMessageTs: res.ts, createdAt: "", }); } } catch (err: any) { console.error( `Failed to forward to ${sub.subscriberChannelId}:`, err?.data || err?.message || err, ); } }}
// --- Hono App ---
const app = new Hono<{ Bindings: Env }>();
// --- oRPC ---const openAPIHandler = new OpenAPIHandler(router, { plugins: [new ZodSmartCoercionPlugin()],});
const rpcHandler = new RPCHandler(router, { plugins: [new ZodSmartCoercionPlugin()],});
const openAPIGenerator = new OpenAPIGenerator({ schemaConverters: [new ZodToJsonSchemaConverter()],});
app.use("/api/*", async (c, next) => { if (c.req.path.startsWith("/api/auth/")) return next();
const dbUrl = getDbUrl(c.env); const db = getDb(dbUrl);
let session: ORPCContext["session"] = null; let apiKey: ORPCContext["apiKey"] = null;
// Resolve session from cookies try { const auth = getAuth(dbUrl, c.env as any); const s = await auth.api.getSession({ headers: c.req.raw.headers }); if (s?.user) { session = { user: s.user as any }; } } catch (err: any) { console.error("Session resolution error:", err.message); }
// Resolve API key from Bearer token (if present) const authHeader = c.req.header("Authorization") || ""; if (authHeader.startsWith("Bearer ")) { try { apiKey = await createApiKeyContext({ req: c.req.raw, databaseUrl: dbUrl, }); } catch { } }
const ctx: ORPCContext = { db, session, apiKey, databaseUrl: dbUrl, slackToken: c.env.SLACK_BOT_TOKEN || "", lockdownUsers: (c.env.LOCKDOWN_USERS || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean), };
const { matched: openMatched, response: openResponse } = await openAPIHandler.handle(c.req.raw, { prefix: "/api", context: ctx, }); if (openMatched) return c.newResponse(openResponse.body, openResponse);
const { matched: rpcMatched, response: rpcResponse } = await rpcHandler.handle(c.req.raw, { context: ctx, prefix: "/api", }); if (rpcMatched) return c.newResponse(rpcResponse.body, rpcResponse); await next();});
app.get("/docs", (c) => { const html = `<!doctype html><html> <head> <title>Indigest API</title> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> </head> <body> <div id="app"></div> <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script> <script> Scalar.createApiReference('#app', { url: '/spec.json', authentication: { preferredSecurityScheme: 'BearerAuth', }, }) </script> </body></html>`; return c.html(html);});
let authDbReady: Record<string, Promise<void>> = {};app.on(["POST", "GET"], "/api/auth/*", async (c) => { try { const dbUrl = getDbUrl(c.env); const auth = getAuth(dbUrl, c.env as any); return auth.handler(c.req.raw); } catch (err: any) { console.error("Auth handler error:", err.message, err.stack); return c.json({ error: "auth handler failed" }, 500); }});
// GitHub stars cachelet cachedStars: number | null = null;let starsFetchedAt = 0;const STARS_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
async function getGithubStars(): Promise<number> { if (cachedStars !== null && Date.now() - starsFetchedAt < STARS_CACHE_TTL) return cachedStars; try { const res = await fetch("https://api.github.com/repos/matmanna/indigest", { headers: { "User-Agent": "indigest-bot", Accept: "application/vnd.github.v3+json", }, }); const data = (await res.json()) as any; if (typeof data.stargazers_count === "number") { cachedStars = data.stargazers_count; starsFetchedAt = Date.now(); } } catch { } if (cachedStars !== null) return cachedStars; return 0;}
// Public site configuration used by the static frontend.app.get("/site-config", async (c) => { c.header("Cache-Control", "no-store"); const env = c.env; const ids = (env.LOCKDOWN_USERS || "") .split(",") .map((id) => id.trim()) .filter(Boolean); const [lockdownUsers, githubStars] = await Promise.all([ ids.length === 0 ? Promise.resolve([]) : (async () => { const slack = new WebClient(env.SLACK_BOT_TOKEN); return Promise.all( ids.map(async (id) => { let name = id; try { const response = await slack.users.info({ user: id }); const user = response.user as any; name = user?.profile?.display_name || user?.name || user?.real_name || id; } catch { } return { id, name, url: `https://hackclub.enterprise.slack.com/team/${id}`, }; }), ); })(), getGithubStars(), ]);
return c.json({ lockdownUsers, githubStars });});
// === Scalar API Docs ===app.get("/spec.json", (c) => { const spec = { openapi: "3.1.0", info: { title: "Indigest API", version: "1.0.0", description: "A Slack bot that curates messages into digestible feeds", }, servers: [{ url: "/" }], security: [{ BearerAuth: [] }], paths: { "/feed/{channelId}": { get: { operationId: "getFeed", summary: "Get channel feed", description: "Returns an RSS feed or JSON feed of messages for a channel. Requires auth if accessPermUsers is not *.", tags: ["Feed"], security: [{ BearerAuth: [] }, { session: [] }], parameters: [ { name: "channelId", in: "path", required: true, schema: { type: "string" }, description: "Slack channel ID", }, { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 }, }, { name: "offset", in: "query", schema: { type: "integer", default: 0 }, }, { name: "api_key", in: "query", schema: { type: "string" }, description: "API key (alternative to Authorization header)", }, ], responses: { "200": { description: "RSS feed (application/rss+xml) or JSON array depending on Accept header or .json suffix", }, "403": { description: "Forbidden — no access to this feed", }, }, }, }, "/api/messages": { get: { operationId: "listMessages", summary: "List messages", description: "List messages for a channel with filtering and pagination. Requires auth if accessPermUsers is not *.", tags: ["Messages"], security: [{ BearerAuth: [] }, { session: [] }], parameters: [ { name: "channel", in: "query", required: true, schema: { type: "string" }, description: "Slack channel ID", }, { name: "after", in: "query", schema: { type: "string", format: "date-time" }, description: "Filter messages after this timestamp", }, { name: "before", in: "query", schema: { type: "string", format: "date-time" }, description: "Filter messages before this timestamp", }, { name: "userId", in: "query", schema: { type: "string" }, description: "Filter by Slack user ID", }, { name: "threadTs", in: "query", schema: { type: "string" }, description: "Filter by thread parent timestamp", }, { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 10000 }, }, { name: "page", in: "query", schema: { type: "integer", default: 1 }, }, ], responses: { "200": { description: "Paginated list of messages", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/Message" }, }, pagination: { $ref: "#/components/schemas/Pagination" }, }, }, }, }, }, }, }, }, "/api/messages/{slackTs}": { get: { operationId: "getMessage", summary: "Get message", description: "Get a single message by its Slack timestamp. Requires auth if accessPermUsers is not *.", tags: ["Messages"], security: [{ BearerAuth: [] }, { session: [] }], parameters: [ { name: "slackTs", in: "path", required: true, schema: { type: "string" }, description: "Slack message timestamp", }, { name: "channel", in: "query", required: true, schema: { type: "string" }, description: "Slack channel ID", }, ], responses: { "200": { description: "Single message", content: { "application/json": { schema: { type: "object", properties: { data: { $ref: "#/components/schemas/Message" }, }, }, }, }, }, }, }, }, "/api/channels": { get: { operationId: "listChannels", summary: "List channels", description: "List all channels known to indigest (enabled and disabled).", tags: ["Channels"], security: [{ basicAuth: [] }], responses: { "200": { description: "All channels", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/Channel" }, }, }, }, }, }, }, }, }, }, "/api/channels/{id}": { get: { operationId: "getChannel", summary: "Get channel", description: "Get a single channel by Slack channel ID.", tags: ["Channels"], security: [{ basicAuth: [] }], parameters: [ { name: "id", in: "path", required: true, schema: { type: "string" }, description: "Slack channel ID", }, ], responses: { "200": { description: "Single channel", content: { "application/json": { schema: { type: "object", properties: { data: { $ref: "#/components/schemas/Channel" }, }, }, }, }, }, "404": { description: "Channel not found", }, }, }, }, "/api/channels/by-user/{userId}": { get: { operationId: "listChannelsByUser", summary: "List channels by user", description: "List channels the user has access to based on accessPermUsers.", tags: ["Channels"], security: [{ BearerAuth: [] }, { session: [] }], parameters: [ { name: "userId", in: "path", required: true, schema: { type: "string" }, description: "Better Auth user ID", }, ], responses: { "200": { description: "Channels the user can access", content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Channel" }, }, }, }, }, }, }, }, "/api/subscriptions": { get: { operationId: "listSubscriptions", summary: "List subscriptions", description: "List all subscriber channels for a given source channel.", tags: ["Subscriptions"], parameters: [ { name: "sourceChannelId", in: "query", required: true, schema: { type: "string" }, description: "Source Slack channel ID", }, ], responses: { "200": { description: "List of subscriptions", content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Subscription" }, }, }, }, }, }, }, post: { operationId: "addSubscription", summary: "Add subscription", description: "Subscribe a channel to a source channel.", tags: ["Subscriptions"], security: [{ BearerAuth: [] }, { session: [] }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["subscriberChannelId", "sourceChannelId"], properties: { subscriberChannelId: { type: "string", description: "Channel to receive forwards", }, sourceChannelId: { type: "string", description: "Channel to forward from", }, }, }, }, }, }, responses: { "200": { description: "Subscription created" }, }, }, delete: { operationId: "removeSubscription", summary: "Remove subscription", description: "Unsubscribe a channel from a source channel.", tags: ["Subscriptions"], security: [{ BearerAuth: [] }, { session: [] }], parameters: [ { name: "subscriberChannelId", in: "query", required: true, schema: { type: "string" }, description: "Channel to unsubscribe", }, { name: "sourceChannelId", in: "query", required: true, schema: { type: "string" }, description: "Source channel to unsubscribe from", }, ], responses: { "200": { description: "Subscription removed" }, }, }, }, "/api/subscriptions/by-subscriber": { get: { operationId: "listSubscriptionsBySubscriber", summary: "List subscriptions by subscriber", description: "List all source channels a subscriber channel is subscribed to.", tags: ["Subscriptions"], parameters: [ { name: "subscriberChannelId", in: "query", required: true, schema: { type: "string" }, description: "Subscriber Slack channel ID", }, ], responses: { "200": { description: "List of subscriptions", content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Subscription" }, }, }, }, }, }, }, }, }, components: { securitySchemes: { BearerAuth: { type: "http", scheme: "bearer", bearerFormat: "API Key", description: "API key (ind_...)", }, session: { type: "apiKey", in: "cookie", name: "better-auth.session_token", description: "Session cookie (browser login)", }, }, schemas: { Channel: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, teamId: { type: "string" }, enabled: { type: "boolean" }, linkMode: { type: "boolean" }, webhookUrl: { type: "string" }, autoApproveUsers: { type: "array", items: { type: "string" } }, manualUsers: { type: "array", items: { type: "string" } }, approvedPosters: { type: "array", items: { type: "string" } }, trackReplies: { type: "boolean" }, metadataSchema: { type: "string" }, metadataRequired: { type: "boolean" }, properties: { type: "string" }, createdAt: { type: "string" }, }, }, Message: { type: "object", properties: { slackTs: { type: "string" }, channelId: { type: "string" }, userId: { type: "string" }, userName: { type: "string" }, authorIds: { type: "array", items: { type: "string" } }, authorNames: { type: "array", items: { type: "string" } }, text: { type: "string" }, timestamp: { type: "string", format: "date-time" }, metadata: { type: "object" }, }, }, Pagination: { type: "object", properties: { page: { type: "integer" }, limit: { type: "integer" }, total: { type: "integer" }, total_pages: { type: "integer" }, }, }, Subscription: { type: "object", properties: { id: { type: "integer" }, subscriberChannelId: { type: "string" }, sourceChannelId: { type: "string" }, profileName: { type: "string", nullable: true }, profileImageUrl: { type: "string", nullable: true }, createdAt: { type: "string" }, }, }, }, }, }; return c.json(spec);});
// === Slack Events ===app.post("/events", async (c) => { const env = c.env; const body = await c.req.text(); const payload = JSON.parse(body);
if (payload.type === "url_verification") { return c.text(payload.challenge, 200, { "Content-Type": "text/plain" }); }
if (!(await verifySignature(c.req.raw, body, env.SLACK_SIGNING_SECRET))) { return c.json({ error: "invalid signature" }, 401); }
// Respond immediately to prevent Slack retries c.res = new Response("ok", { status: 200, headers: { "Content-Type": "text/plain" }, });
c.executionCtx.waitUntil( (async () => { console.log( "Received Slack event:", payload.event?.type, "thread_ts:", payload.event?.thread_ts, "channel:", payload.event?.channel, ); const ev = payload.event; const db = getDb(getDbUrl(env)); const slack = new WebClient(env.SLACK_BOT_TOKEN);
if (ev.type === "member_joined_channel") { const auth = await slack.auth.test(); if (ev.user !== auth.user_id) return; const existing = await q.getChannel(db, ev.channel); if (existing) { try { const conv = await slack.conversations.info({ channel: ev.channel }); const name = (conv.channel as any)?.name || ""; if (name && name !== existing.name) { existing.name = name; await q.upsertChannel(db, existing); } } catch { } return; } const ch: StoreChannel = { id: ev.channel, name: "", teamId: auth.team_id || "", enabled: false, linkMode: false, webhookUrl: "", autoApproveUsers: [], manualUsers: [], triggerMode: "message", approvedPosters: [], accessPermUsers: ["*"], trackReplies: false, metadataSchema: "", metadataRequired: false, properties: "", createdAt: "", }; await q.upsertChannel(db, ch); try { const conv = await slack.conversations.info({ channel: ev.channel }); ch.name = (conv.channel as any)?.name || ""; await q.upsertChannel(db, ch); } catch { } }
if (ev.type === "channel_rename") { const renamedChannelId = ev.channel?.id || ev.channel_id; const renamedName = ev.channel?.name || ev.name; if (renamedChannelId && renamedName) { const renamedChannel = await q.getChannel(db, renamedChannelId); if (renamedChannel) { renamedChannel.name = renamedName; await q.upsertChannel(db, renamedChannel); } } return; }
if (ev.type === "message" && !ev.thread_ts) { const ch = await q.getChannel(db, ev.channel); if (!ch || !ch.enabled) return;
let selfUserId: string | undefined; let selfBotId: string | undefined; if (ev.subtype === "bot_message") { const auth = await slack.auth.test(); selfUserId = auth.user_id; selfBotId = (auth as any).bot_id; } const admission = resolveMessageAdmission({ subtype: ev.subtype, user: ev.user, botId: ev.bot_id, selfUserId, selfBotId, autoApproveUsers: ch.autoApproveUsers, manualUsers: ch.manualUsers, triggerMode: ch.triggerMode, }); if (!admission.allowed) return;
await syncChannelName(db, slack, ch); logMessageContentProbe("incoming", ev);
// Log full event for debugging empty forward messages if (!ev.text && !ev.subtype) { console.log( "Empty message event:", JSON.stringify({ text: ev.text, subtype: ev.subtype, attachments: ev.attachments, files: ev.files, x_files: ev.x_files, user: ev.user, }), ); }
const disposition = admission.disposition;
if (disposition === "auto") { console.log(`Auto-approving message from ${ev.user} in #${ch.name}`); let userName = ev.user; try { const u = await slack.users.info({ user: ev.user }); userName = (u.user as any)?.name || ev.user; } catch { } const metadata = {}; const rawText = extractMessageText(ev); const text = resolveSlackMrkdwn(rawText); const originalAuthor = extractOriginalAuthor(ev); const storedTimestamp = resolveMessageTimestamp(ev, ev.ts); const storedAuthors = resolveStoredAuthors(ev, ev.user, userName);
await q.upsertMessage(db, { slackTs: ev.ts, channelId: ev.channel, userId: storedAuthors.userId, userName: storedAuthors.userName, text, timestamp: storedTimestamp, metadata, }); await q.addBotAction(db, { type: "approve", sourceChannelId: ev.channel, sourceMessageTs: ev.ts, botChannelId: ev.channel, botMessageTs: "0", command: "auto_approve", createdAt: "", }); await fireWebhook(ch, { slackTs: ev.ts, channelId: ev.channel, userId: storedAuthors.userId, userName: storedAuthors.userName, text, timestamp: storedTimestamp, metadata, }); await forwardToSubscribers( { slackTs: ev.ts, channelId: ev.channel, userId: ev.user, userName, text, rawText, originalUserId: originalAuthor.userId, originalUserName: originalAuthor.userName, timestamp: storedTimestamp, metadata, }, ch, db, slack, ); // if (ch.linkMode) await postPermalinkReply(slack, ev.channel, ev.ts);
// If channel has a metadata schema, prompt the user to add metadata if (ch.metadataSchema) { let schema: any = null; try { schema = JSON.parse(ch.metadataSchema); } catch { } if (schema && schema.fields?.length > 0) { try { await slack.chat.postMessage({ channel: ev.channel, thread_ts: ev.ts, text: "Your message has been posted to the feed!", blocks: [ { type: "section", text: { type: "mrkdwn", text: "📰 Your message has been posted to the feed! Would you like to add some extra data?", }, accessory: { type: "button", action_id: "indigest_metadata", text: { type: "plain_text", text: "Add Metadata" }, value: ev.ts, style: "primary" as const, }, }, ], }); } catch { } } } } else if (disposition === "prompt") { // Non-auto-approve: prompt for manual approval const section = { type: "section", text: { type: "mrkdwn", text: "Expose this message to indigest via RSS and API?", }, }; const actions = { type: "actions", elements: [ { type: "button", action_id: "indigest_yes", text: { type: "plain_text", text: "Yep!" }, value: ev.ts, }, { type: "button", action_id: "indigest_no", text: { type: "plain_text", text: "No thanks" }, style: "danger" as const, value: ev.ts, }, ], }; try { await slack.chat.postMessage({ channel: ev.channel, thread_ts: ev.ts, text: "Expose this message to indigest via RSS and API?", blocks: [section, actions], }); } catch { } } }
// Thread reply tracking (separate from the top-level message handler above) if (ev.type === "message" && !ev.subtype && ev.thread_ts) { console.log( `Thread reply: channel=${ev.channel} thread_ts=${ev.thread_ts} ts=${ev.ts}`, ); const ch = await q.getChannel(db, ev.channel); console.log( `Channel lookup: ch=${!!ch} enabled=${ch?.enabled} trackReplies=${ch?.trackReplies}`, ); if (!ch || !ch.enabled || !ch.trackReplies) return;
// Store the parent message if not already stored const existingParent = ( await q.getMessages(db, ev.channel, { limit: 200 }) ).find((m) => m.slackTs === ev.thread_ts); if (!existingParent) { try { const parentHistory = await slack.conversations.history({ channel: ev.channel, latest: ev.thread_ts!, limit: 1, inclusive: true, }); const parentMsg = parentHistory.messages?.[0] as any; if (parentMsg && !parentMsg.subtype) { let parentUserName = parentMsg.user || ""; try { const u = await slack.users.info({ user: parentMsg.user }); parentUserName = (u.user as any)?.name || parentMsg.user; } catch { } await q.upsertMessage(db, { slackTs: ev.thread_ts!, channelId: ev.channel, userId: parentMsg.user || "", userName: parentUserName, text: resolveSlackMrkdwn(extractMessageText(parentMsg)), timestamp: slackTsToTime(ev.thread_ts!).toISOString(), metadata: {}, }); } } catch { } }
// Store the reply let userName = ev.user; try { const u = await slack.users.info({ user: ev.user }); userName = (u.user as any)?.name || ev.user; } catch { } await q.upsertMessage(db, { slackTs: ev.ts, channelId: ev.channel, threadTs: ev.thread_ts, userId: ev.user, userName, text: resolveSlackMrkdwn(ev.text), timestamp: slackTsToTime(ev.ts).toISOString(), metadata: {}, }); console.log( `Thread reply stored: ts=${ev.ts} threadTs=${ev.thread_ts}`, ); } })(), );});
// === Interactive Components ===app.post("/interactions", async (c) => { const env = c.env; const body = await c.req.text(); const formData = new URLSearchParams(body); const payloadStr = formData.get("payload"); if (!payloadStr) return c.json({ error: "missing payload" }, 400);
const cb = JSON.parse(payloadStr);
// Metadata modal must open BEFORE responding — trigger_id expires in 3 seconds if (cb.type === "block_actions") { const action = cb.actions?.[0]; if ( action?.action_id === "indigest_metadata" || action?.action_id === "indigest_edit_metadata" ) { const db = getDb(getDbUrl(env)); const slack = new WebClient(env.SLACK_BOT_TOKEN); const channelId = cb.channel?.id; const messageTs = action.value; const triggerId = cb.trigger_id; const responseUrl = cb.response_url;
const ch = await q.getChannel(db, channelId); if (!ch || !ch.enabled || !ch.metadataSchema) { return c.json({ response_action: "update", view: { type: "modal", title: { type: "plain_text", text: "Error" }, blocks: [ { type: "section", text: { type: "mrkdwn", text: "No metadata schema configured for this channel.", }, }, ], }, }); } let schema: any = null; try { schema = JSON.parse(ch.metadataSchema); } catch { } if (!schema || schema.fields?.length === 0) { return c.json({ response_action: "update", view: { type: "modal", title: { type: "plain_text", text: "Error" }, blocks: [ { type: "section", text: { type: "mrkdwn", text: "No metadata schema configured for this channel.", }, }, ], }, }); } const { openMetadataModal } = await import("./api/modal"); let initialMetadata: Record<string, any> = {}; if (action.action_id === "indigest_edit_metadata") { const storedMessage = await q.getMessageBySlackTs( db, channelId, messageTs, ); if (storedMessage?.metadata) { try { initialMetadata = typeof storedMessage.metadata === "string" ? JSON.parse(storedMessage.metadata) : storedMessage.metadata; } catch { } } } try { await openMetadataModal( slack, triggerId, channelId, messageTs, schema, cb.container?.message_ts, false, initialMetadata, ); } catch (err: any) { return c.json({ response_action: "update", view: { type: "modal", title: { type: "plain_text", text: "Error" }, blocks: [ { type: "section", text: { type: "mrkdwn", text: `Error opening form: ${err.message}`, }, }, ], }, }); }
// Permission check after modal opens c.executionCtx.waitUntil( (async () => { const clickingUser = cb.user?.id || ""; const lockdownUsers = (env.LOCKDOWN_USERS || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean); const isLockdown = lockdownUsers.includes(clickingUser); const isManager = isLockdown ? false : await isChannelManager(channelId, clickingUser, slack); const msgs = await q.getMessages(db, channelId, { limit: 100 }); const originalMsg = msgs.find((m) => m.slackTs === messageTs); let messageUser = originalMsg?.userId || ""; if (!messageUser) { try { const history = await slack.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true, }); messageUser = (history.messages?.[0] as any)?.user || ""; } catch { } } if ( !canApproveMessage({ approvedPosters: ch.approvedPosters, clickingUser, messageUser, isManager, isLockdown, }) ) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ You don't have permission to edit metadata for this message.", }); } })(), ); return c.json({}); } }
if (cb.type === "message_action" && cb.callback_id === "edit_bot_message") { const channelId = cb.channel?.id; const messageTs = cb.message?.ts; const clickingUser = cb.user?.id || ""; const slack = new WebClient(env.SLACK_BOT_TOKEN); const lockdownUsers = (env.LOCKDOWN_USERS || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean);
if (!canEditBotMessage(clickingUser, lockdownUsers)) { if (channelId) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ Only lockdown users can edit bot messages.", }); } return c.json({}); }
if (!channelId || !messageTs) return c.json({}); try { const history = await slack.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true, }); const message = history.messages?.[0] as any; const auth = await slack.auth.test(); const isOwnBotMessage = message && (message.user === auth.user_id || (message.bot_id && message.bot_id === (auth as any).bot_id)); if (!isOwnBotMessage) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ This action can only edit messages posted by indigest.", }); return c.json({}); }
const { openBotMessageEditModal } = await import("./api/modal"); await openBotMessageEditModal( slack, cb.trigger_id, channelId, messageTs, message.text || "", ); } catch (err: any) { if (channelId) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: `❌ Could not open the edit form: ${err.message}`, }); } } return c.json({}); }
// Respond immediately for everything else to prevent Slack timeout c.res = new Response(JSON.stringify({}), { status: 200, headers: { "Content-Type": "application/json" }, });
c.executionCtx.waitUntil( (async () => { const db = getDb(getDbUrl(env)); const slack = new WebClient(env.SLACK_BOT_TOKEN);
if ( cb.type === "view_submission" && cb.view?.callback_id === "metadata_modal" ) { const privateMeta = JSON.parse(cb.view.private_metadata || "{}"); const { channelId, messageTs, botMessageTs, apiOnly } = privateMeta; const channel = await q.getChannel(db, channelId); if (!channel || !channel.enabled) return; const client = new WebClient(env.SLACK_BOT_TOKEN); let approvalHistory: any = null; const storedOriginal = ( await q.getMessages(db, channelId, { limit: 200 }) ).find((m) => m.slackTs === messageTs); let approvalMessageUser = storedOriginal?.userId || ""; if (!approvalMessageUser) { try { approvalHistory = await client.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true, }); approvalMessageUser = (approvalHistory.messages?.[0] as any)?.user || ""; } catch { approvalMessageUser = ""; } }
// The modal may have been opened before authorization completed. The // submission is the final security boundary: reject unauthorized // users before parsing uploads, saving metadata, or forwarding. const clickingUser = cb.user?.id || ""; const lockdownUsers = (env.LOCKDOWN_USERS || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean); const isLockdown = lockdownUsers.includes(clickingUser); const isManager = isLockdown ? false : await isChannelManager(channelId, clickingUser, slack); if (!isManager && !isLockdown) { if ( !canApproveMessage({ approvedPosters: channel.approvedPosters, clickingUser, messageUser: approvalMessageUser, isManager: false, isLockdown: false, }) ) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ You don't have permission to approve this message.", }); return; } }
let schema: any = null; try { schema = JSON.parse(channel.metadataSchema); } catch { }
const metadata = schema ? await (async () => { const m: Record<string, any> = {}; for (const field of schema.fields || []) { const values = cb.view.state?.values?.[`field_${field.action_id}`]?.[ field.action_id ]; if (!values) continue; if (field.type === "multi_static_select") { if (values.selected_options?.length) { m[field.action_id] = values.selected_options.map( (o: any) => o.value, ); } } else if (field.type === "datepicker") { if (values.selected_date) { m[field.action_id] = values.selected_date; } } else if (field.type === "file_input") { const files = values.files || []; if (files.length) { m[field.action_id] = await Promise.all( files.map(async (f: any) => { if (!f.url_private) return { ...f, cdn_url: null }; const cdnUrl = await uploadToCDN( f.url_private, env.SLACK_BOT_TOKEN, env.HACK_CLUB_CDN_KEY || "", ); return { ...f, cdn_url: cdnUrl }; }), ); } } else if (values.value) { m[field.action_id] = values.value; } } return JSON.stringify(m); })() : "";
if (channel.metadataRequired && !hasProvidedMetadata(metadata)) { await client.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ Metadata is required before this message can be forwarded.", }); return; }
try { const history = approvalHistory || await client.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true, }); const msg = history.messages?.[0] as any; if (!msg) return;
let userName = msg.user || ""; try { const u = await client.users.info({ user: msg.user }); userName = (u.user as any)?.name || userName; } catch { }
const originalAuthor = extractOriginalAuthor(msg); const storedUserId = q.mergeAuthorLists( msg.user || "", originalAuthor.userId, ); const storedUserName = q.mergeAuthorLists( userName, originalAuthor.userName, ); const storedTimestamp = resolveMessageTimestamp( msg, messageTs, storedOriginal?.timestamp, ); const storedAuthors = resolveStoredAuthors( msg, msg.user || "", userName, );
await q.upsertMessage(db, { slackTs: messageTs, channelId, userId: storedAuthors.userId, userName: storedAuthors.userName, text: resolveSlackMrkdwn(extractMessageText(msg)), timestamp: storedTimestamp, metadata, }); await q.addBotAction(db, { type: "approve", sourceChannelId: channelId, sourceMessageTs: messageTs, botChannelId: channelId, botMessageTs: botMessageTs || "0", userId: clickingUser, command: "metadata_submit", createdAt: "", });
await fireWebhook(channel, { slackTs: messageTs, channelId, userId: storedAuthors.userId, userName: storedAuthors.userName, text: resolveSlackMrkdwn(extractMessageText(msg)), timestamp: storedTimestamp, metadata, });
// Build metadata section for editing subscriber messages const parsedMetadata = metadata ? JSON.parse(metadata) : null; const parsedSchema = channel.metadataSchema ? JSON.parse(channel.metadataSchema) : null; const metadataSection = parsedMetadata && parsedSchema ? Object.keys(parsedMetadata) .map((metaProp) => { const field = parsedSchema.fields?.find( (e: any) => e.action_id === metaProp, ); return `*${field?.label || metaProp}:* ${parsedMetadata[metaProp]}`; }) .join("\n") : "";
// Find existing bot actions for this source message and edit them with metadata const botActions = await q.getBotActionsBySource(db, channelId, messageTs, ); for (const action of botActions) { if (action.type === "pub" && metadataSection) { try { // Fetch the current bot message to get its blocks const botMsg = await client.conversations.history({ channel: action.botChannelId, latest: action.botMessageTs, limit: 1, inclusive: true, }); const existingMsg = botMsg.messages?.[0] as any; if (existingMsg?.blocks) { const blocks = upsertMetadataBlock( existingMsg.blocks, metadataSection, ); await client.chat.update({ channel: action.botChannelId, ts: action.botMessageTs, text: existingMsg.text || "", blocks, } as any); } } catch (err: any) { console.error( `Failed to update bot message ${action.botChannelId}:${action.botMessageTs}:`, err.message, ); } } }
// Also forward to any new subscribers that weren't captured in the initial forward if (!apiOnly) { const originalAuthor = extractOriginalAuthor(msg); await forwardToSubscribers( { slackTs: messageTs, channelId, userId: msg.user || "", userName, text: resolveSlackMrkdwn(extractMessageText(msg)), rawText: extractMessageText(msg), originalUserId: originalAuthor.userId, originalUserName: originalAuthor.userName, timestamp: storedTimestamp, metadata, }, channel, db, slack, ); } // if (channel.linkMode) await postPermalinkReply(client, channelId, messageTs);
// Update the bot's prompt to show approved state if (botMessageTs) { try { await client.chat.update({ channel: channelId, ts: botMessageTs, text: "✅ Message approved and added to the feed!", blocks: [ { type: "section", text: { type: "mrkdwn", text: "✅ *Message approved and added to the feed!*", }, }, { type: "actions", elements: [ { type: "button", action_id: "indigest_no", text: { type: "plain_text", text: "Undo" }, style: "danger" as const, value: messageTs, }, ...(channel.metadataSchema ? [{ type: "button", action_id: "indigest_edit_metadata", text: { type: "plain_text", text: "Edit metadata" }, value: messageTs, }] : []), ], }, ], } as any); } catch (err: any) { console.error( "chat.update (view_submission) failed:", err.message, ); } } } catch (err: any) { console.error("view_submission error:", err.message); } return; }
if ( cb.type === "view_submission" && cb.view?.callback_id === "edit_bot_message_modal" ) { const privateMeta = JSON.parse(cb.view.private_metadata || "{}"); const { channelId, messageTs } = privateMeta; const clickingUser = cb.user?.id || ""; const lockdownUsers = (env.LOCKDOWN_USERS || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean); if (!canEditBotMessage(clickingUser, lockdownUsers)) return;
const value = cb.view.state?.values?.edit_message?.edit_message?.value || ""; if (!channelId || !messageTs || !value.trim()) return;
const history = await slack.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true, }); const message = history.messages?.[0] as any; const auth = await slack.auth.test(); const isOwnBotMessage = message && (message.user === auth.user_id || (message.bot_id && message.bot_id === (auth as any).bot_id)); if (!isOwnBotMessage) return;
const update: any = { channel: channelId, ts: messageTs, text: value.trim(), }; if (Array.isArray(message.blocks) && message.blocks.length > 0) { const blocks = message.blocks.map((block: any) => ({ ...block })); const firstTextBlock = blocks.find( (block: any) => block.type === "section" && block.text && typeof block.text.text === "string", ); if (firstTextBlock) { firstTextBlock.text = { ...firstTextBlock.text, text: value.trim() }; update.blocks = blocks; } } await slack.chat.update(update); return; }
// Backfill shortcuts: manually pub a message if (cb.type === "message_action") { const channelId = cb.channel?.id; const messageTs = cb.message?.ts; const triggerId = cb.trigger_id; const clickingUser = cb.user?.id || ""; const apiOnly = cb.callback_id === "backfill_api_only";
if (!channelId || !messageTs) { await slackResponse( cb.response_url || "", "Could not identify the message to backfill.", ); return c.json({}); }
c.executionCtx.waitUntil( (async () => { const db = getDb(getDbUrl(env)); const slack = new WebClient(env.SLACK_BOT_TOKEN);
const lockdownUsers = (env.LOCKDOWN_USERS || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean); const isLockdown = lockdownUsers.includes(clickingUser); const isManager = isLockdown || (await isChannelManager(channelId, clickingUser, slack));
if (!isManager) { const ch = await q.getChannel(db, channelId); if (!ch || !ch.enabled) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ This channel isn't enabled for indigest. Run `/in pub` first.", }); return; } }
try { const history = await slack.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true, }); const msg = history.messages?.[0] as any; if (!msg) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ That message can't be added to the feed (system message).", }); return; }
let userName = msg.user || ""; const userId = msg.user || msg.bot_id || ""; if (msg.user) { try { const u = await slack.users.info({ user: msg.user }); userName = (u.user as any)?.name || msg.user; } catch { } } else if (msg.bot_id) { userName = msg.username || msg.bot_id; }
const originalAuthor = extractOriginalAuthor(msg); const storedUserId = q.mergeAuthorLists(userId, originalAuthor.userId); const storedUserName = q.mergeAuthorLists(userName, originalAuthor.userName); const storedTimestamp = resolveMessageTimestamp(msg, messageTs); const storedAuthors = resolveStoredAuthors(msg, userId, userName);
const metadata = {};
const ch = await q.getChannel(db, channelId);
// If channel has a metadata schema, open modal instead of posting immediately if (ch?.metadataSchema) { let schema: any = null; try { schema = JSON.parse(ch.metadataSchema); } catch { } if (schema && schema.fields?.length > 0) { try { const { openMetadataModal } = await import("./api/modal"); await openMetadataModal( slack, triggerId, channelId, messageTs, schema, undefined, apiOnly, ); } catch (err: any) { console.error("backfill openMetadataModal failed:", err.message); await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: `❌ Error opening form: ${err.message}`, }); } return; } }
await q.upsertMessage(db, { slackTs: messageTs, channelId, userId: storedAuthors.userId, userName: storedAuthors.userName, text: resolveSlackMrkdwn(extractMessageText(msg)), timestamp: storedTimestamp, metadata, });
const savedMsg = { ...(() => { const originalAuthor = extractOriginalAuthor(msg); return { originalUserId: originalAuthor.userId, originalUserName: originalAuthor.userName, }; })(), slackTs: messageTs, channelId, userId, userName, text: resolveSlackMrkdwn(extractMessageText(msg)), rawText: extractMessageText(msg), timestamp: storedTimestamp, metadata, }; await q.addBotAction(db, { type: "backfill", sourceChannelId: channelId, sourceMessageTs: messageTs, botChannelId: channelId, botMessageTs: "0", userId: clickingUser, command: apiOnly ? "backfill_api_only" : "backfill", createdAt: "", }); if (ch) await fireWebhook(ch, savedMsg); if (ch && !apiOnly) await forwardToSubscribers(savedMsg, ch, db, slack); } catch (err: any) { console.error("backfill shortcut error:", err.message); await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: `❌ Error backfilling message: ${err.message}`, }); } })(), ); return c.json({}); }
if (cb.type !== "block_actions") return;
const action = cb.actions?.[0]; if (!action) return;
const channelId = cb.channel?.id; const messageTs = action.value; const responseUrl = cb.response_url; const triggerId = cb.trigger_id; const botMessageTs = cb.container?.message_ts || "";
const ch = await q.getChannel(db, channelId); if (!ch || !ch.enabled) { await slackResponse(responseUrl, "This channel is not enabled."); return; }
if (action.action_id === "indigest_yes") { const clickingUser = cb.user?.id || "";
// Open modal IMMEDIATELY — trigger_id expires in 3s, can't afford slow checks first if (ch.metadataSchema) { let schema: any = null; try { schema = JSON.parse(ch.metadataSchema); } catch { } if (schema && schema.fields?.length > 0) { const { openMetadataModal } = await import("./api/modal"); try { await openMetadataModal( slack, triggerId, channelId, messageTs, schema, cb.container?.message_ts, ); } catch (err: any) { console.error("openMetadataModal failed:", err.message); if (err.message?.includes("expired_trigger_id")) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "⏱️ Form expired — please click Approve again.", }); } else { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: `❌ Error opening form: ${err.message}`, }); } return; }
// Permission check AFTER modal is open const lockdownUsers = (env.LOCKDOWN_USERS || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean); const isLockdown = lockdownUsers.includes(clickingUser); const isManager = isLockdown || (await isChannelManager(channelId, clickingUser, slack)); if (!isManager) { const msgs = await q.getMessages(db, channelId, { limit: 100 }); const originalMsg = msgs.find((m) => m.slackTs === messageTs); let messageUser = originalMsg?.userId || ""; if (!messageUser) { try { const history = await slack.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true, }); messageUser = (history.messages?.[0] as any)?.user || ""; } catch { } } const allowed = canApproveMessage({ approvedPosters: ch.approvedPosters, clickingUser, messageUser, isManager: false, isLockdown, }); if (!allowed) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ You don't have permission to approve this message.", }); return; } } return; } }
// No metadata schema — do permission checks first, then approve directly const lockdownUsers = (env.LOCKDOWN_USERS || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean); const isLockdown = lockdownUsers.includes(clickingUser); const isManager = isLockdown || (await isChannelManager(channelId, clickingUser, slack));
try { const history = await slack.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true, }); const msg = history.messages?.[0] as any; if (!msg) { await slackResponse(responseUrl, "Couldn't fetch that message."); return; }
if ( !canApproveMessage({ approvedPosters: ch.approvedPosters, clickingUser, messageUser: msg.user || "", isManager, isLockdown, }) ) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ You don't have permission to approve this message.", }); return; }
if (ch.metadataRequired) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ This channel requires metadata before messages can be forwarded.", }); return; }
await q.addBotAction(db, { type: "approve", sourceChannelId: channelId, sourceMessageTs: messageTs, botChannelId: channelId, botMessageTs: botMessageTs || "0", userId: clickingUser, command: "indigest_yes", createdAt: "", });
let userName = msg.user || ""; try { const u = await slack.users.info({ user: msg.user }); userName = (u.user as any)?.name || userName; } catch { }
const originalAuthor = extractOriginalAuthor(msg); const storedUserId = q.mergeAuthorLists( msg.user || "", originalAuthor.userId, ); const storedUserName = q.mergeAuthorLists( userName, originalAuthor.userName, ); const storedTimestamp = resolveMessageTimestamp(msg, messageTs); const storedAuthors = resolveStoredAuthors( msg, msg.user || "", userName, );
await q.upsertMessage(db, { slackTs: messageTs, channelId, userId: storedAuthors.userId, userName: storedAuthors.userName, text: resolveSlackMrkdwn(extractMessageText(msg)), timestamp: storedTimestamp, metadata: {}, });
const savedMsg = { ...(() => { const originalAuthor = extractOriginalAuthor(msg); return { originalUserId: originalAuthor.userId, originalUserName: originalAuthor.userName, }; })(), slackTs: messageTs, channelId, userId: msg.user || "", userName, text: resolveSlackMrkdwn(extractMessageText(msg)), rawText: extractMessageText(msg), timestamp: storedTimestamp, metadata: {}, }; await fireWebhook(ch, savedMsg); await forwardToSubscribers(savedMsg, ch, db, slack); // if (ch.linkMode) await postPermalinkReply(slack, channelId, messageTs);
// Update the bot's prompt to show approved state if (botMessageTs) { try { await slack.chat.update({ channel: channelId, ts: botMessageTs, text: "✅ Message approved and added to the feed!", blocks: [ { type: "section", text: { type: "mrkdwn", text: "✅ *Message approved and added to the feed!*", }, }, { type: "actions", elements: [ { type: "button", action_id: "indigest_no", text: { type: "plain_text", text: "Undo" }, style: "danger" as const, value: messageTs, }, ...(ch.metadataSchema ? [{ type: "button", action_id: "indigest_edit_metadata", text: { type: "plain_text", text: "Edit metadata" }, value: messageTs, }] : []), ], }, ], } as any); } catch (err: any) { console.error( "chat.update (yes) failed:", err.message, "botMessageTs:", botMessageTs, "channel:", channelId, ); } } else { console.warn( "indigest_yes: botMessageTs is empty, skipping chat.update. container:", JSON.stringify(cb.container), ); }
await slackResponse(responseUrl, ""); } catch (err: any) { await slackResponse(responseUrl, `Error: ${err.message}`); } } else if (action.action_id === "indigest_no") { const clickingUser = cb.user?.id || ""; const lockdownUsers = (env.LOCKDOWN_USERS || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean); const isLockdown = lockdownUsers.includes(clickingUser); const isManager = isLockdown ? false : await isChannelManager(channelId, clickingUser, slack); let messageUser = (await q.getMessageBySlackTs(db, channelId, messageTs))?.userId || ""; if (!messageUser) { try { const history = await slack.conversations.history({ channel: channelId, latest: messageTs, limit: 1, inclusive: true, }); messageUser = (history.messages?.[0] as any)?.user || ""; } catch { } } if (!canApproveMessage({ approvedPosters: ch.approvedPosters, clickingUser, messageUser, isManager, isLockdown, })) { await slack.chat.postEphemeral({ channel: channelId, user: clickingUser, text: "❌ You don't have permission to decline this message.", }); return; }
await q.addBotAction(db, { type: "decline", sourceChannelId: channelId, sourceMessageTs: messageTs, botChannelId: channelId, botMessageTs: botMessageTs || "0", userId: clickingUser, command: "indigest_no", createdAt: "", });
// Remove the message from the DB if it was previously approved try { await q.deleteMessage(db, channelId, messageTs); } catch (err: any) { console.error("deleteMessage failed:", err.message); }
// Update the bot's prompt to show declined state if (botMessageTs) { try { await slack.chat.update({ channel: channelId, ts: botMessageTs, text: "❌ Message declined.", blocks: [ { type: "section", text: { type: "mrkdwn", text: "❌ *Message declined.*" }, }, { type: "actions", elements: [ { type: "button", action_id: "indigest_yes", text: { type: "plain_text", text: "Approve instead" }, value: messageTs, }, ], }, ], } as any); } catch (err: any) { console.error( "chat.update (no) failed:", err.message, "botMessageTs:", botMessageTs, "channel:", channelId, ); } } else { console.warn( "indigest_no: botMessageTs is empty, skipping chat.update. container:", JSON.stringify(cb.container), ); }
await slackResponse(responseUrl, ""); }
return; })(), );});
// === Slash Commands ===
type SubOverrideCtx = { subcmd: string; arg: string; subscriberChannelId: string;};
async function handleSubOverride( db: any, slack: WebClient, respond: (msg: string) => Promise<void>, logCommand: (cmd: string, db: any) => Promise<void>, ctx: SubOverrideCtx,): Promise<void> { const { subcmd, arg, subscriberChannelId } = ctx; const name = subcmd === "name" ? "bot username" : "profile picture";
if (!arg) { await respond( `Usage: \`/in sub ${subcmd} #source <value>\` — set the ${name} for forwards from #source. Use \`[property]\` to pull from the source channel's properties, or \`clear\` to reset.`, ); return; }
const match = arg.match(/^(<#(\w+)(\|[^>]*)?>|#(\S+))\s*([\s\S]*)$/); if (!match) { await respond( `Usage: \`/in sub ${subcmd} #source <value>\` — I couldn't parse a source channel.`, ); return; } const idFromRef = match[2]; const nameFromRef = match[4]; const value = (match[5] || "").trim();
let sourceId: string; if (idFromRef) { sourceId = idFromRef; } else { try { const list = await slack.conversations.list({ types: "public_channel,private_channel", limit: 1000, }); const channels = (list.channels as any[]) || []; const found = channels.find((ch) => ch.name === nameFromRef); if (!found) { await respond(`Channel #${nameFromRef} not found.`); return; } sourceId = found.id; } catch { await respond("Couldn't look up channels."); return; } }
const existing = await q.getSubscriptionsBySubscriber(db, subscriberChannelId); const sub = existing.find((s) => s.sourceChannelId === sourceId); if (!sub) { await respond( `This channel isn't subscribed to #${nameFromRef || sourceId} yet. Run \`/in sub #${nameFromRef || sourceId}\` first.`, ); return; }
if (value.toLowerCase() === "clear") { await q.updateSubscriptionOverrides(db, subscriberChannelId, sourceId, { profileName: subcmd === "name" ? null : undefined, profileImageUrl: subcmd === "pfp" ? null : undefined, }); await logCommand(`sub ${subcmd} clear`, db); await respond(`${name} override cleared for this channel.`); return; }
await q.updateSubscriptionOverrides(db, subscriberChannelId, sourceId, { profileName: subcmd === "name" ? value : undefined, profileImageUrl: subcmd === "pfp" ? value : undefined, }); await logCommand(`sub ${subcmd} ${value}`, db); await respond( `📰 ${name} override set for forwards from #${nameFromRef || sourceId}.${value.startsWith("[") ? " (resolved from properties)" : ""}`, );}
const slackSlashCommand = async (c: any) => { const env = c.env; const body = await c.req.text(); const form = new URLSearchParams(body);
const sourceChannelId = form.get("channel_id") || ""; const userId = form.get("user_id") || ""; const text = (form.get("text") || "").trim(); const responseUrl = form.get("response_url") || "";
const respond = async (msg: string) => { if (responseUrl) { await slackResponse(responseUrl, msg); } };
const logCommand = async (command: string, db: any) => { try { await db.insert(schema.botActions).values({ type: "command", sourceChannelId, sourceMessageTs: "0", botChannelId: sourceChannelId, botMessageTs: "0", userId, command, }); } catch {} };
c.header("Content-Type", "application/json"); c.header("X-Slack-Response-Accepted", "1"); c.res = new Response( JSON.stringify({ response_type: "ephemeral", text: "⏳ Processing..." }), { status: 200, headers: c.res.headers }, );
c.executionCtx.waitUntil( (async () => { const db = getDb(getDbUrl(env)); const slack = new WebClient(env.SLACK_BOT_TOKEN); const baseUrl = env.BASE_URL || "http://localhost:8787"; const lockdownUsers = (env.LOCKDOWN_USERS || "") .split(",") .map((s: string) => s.trim()) .filter(Boolean);
const channelRefMatch = text.match( /^(<#(\w+)(\|[^>]*)?>|#(\S+))\s*(.*)$/, ); let targetChannelId: string; let rest: string;
if (channelRefMatch) { const idFromRef = channelRefMatch[2]; const nameFromRef = channelRefMatch[4]; rest = channelRefMatch[5]!.trim();
if (idFromRef) { targetChannelId = idFromRef; } else { try { const list = await slack.conversations.list({ types: "public_channel,private_channel", limit: 1000, }); const channels = (list.channels as any[]) || []; const found = channels.find((c: any) => c.name === nameFromRef); if (!found) { await respond(`Channel #${nameFromRef} not found.`); return; } targetChannelId = found.id; } catch { await respond("Couldn't look up channels."); return; } } } else { targetChannelId = sourceChannelId; rest = text; }
let ch = await q.getChannel(db, targetChannelId); if (!ch) { let name = targetChannelId; try { const conv = await slack.conversations.info({ channel: targetChannelId, }); name = (conv.channel as any)?.name || targetChannelId; } catch { } const auth = await slack.auth.test(); ch = { id: targetChannelId, name, teamId: auth.team_id || "", enabled: false, linkMode: false, webhookUrl: "", autoApproveUsers: [], manualUsers: [], triggerMode: "message", approvedPosters: [], accessPermUsers: ["*"], trackReplies: false, metadataSchema: "", metadataRequired: false, properties: "", createdAt: "", }; await q.upsertChannel(db, ch); }
const parts = rest.split(/\s+/); const cmd = parts[0]; const subcmd = parts[1]; const arg = parts.slice(2).join(" "); console.log( `Slash command: user=${userId} channel=${targetChannelId} cmd=${cmd} subcmd=${subcmd} arg=${arg}`, ); const targetManager = () => lockdownUsers.includes(userId) || isChannelManager(targetChannelId, userId, slack);
if ( lockdownUsers.length > 0 && !lockdownUsers.includes(userId) && cmd !== "status" && cmd !== "" ) { await respond( "🔒 indigest is in lockdown mode. Only authorized users can run commands.", ); return; }
const doUnpub = async (logTag: string) => { if (!(await targetManager())) { await respond("Only the channel creator can unpub indigest."); return; } ch.enabled = false; await q.upsertChannel(db, ch); await logCommand(logTag, db); const label = targetChannelId === sourceChannelId ? "" : ` in #${ch.name}`; await respond(`indigest unpub'd${label}.`); };
const doUnsub = async (unsubArg: string) => { const unsubChannelMatch = unsubArg.match( /^(<#(\w+)(\|[^>]*)?>|#(\S+))\s*$/, ); if (!unsubChannelMatch) { await respond( "Usage: \`/in unsub #channel\` | \`/in sub off #channel\`", ); return; } const unsubIdFromRef = unsubChannelMatch[2]; const unsubNameFromRef = unsubChannelMatch[4]; let sourceId: string; let sourceName: string; if (unsubIdFromRef) { sourceId = unsubIdFromRef; } else { try { const list = await slack.conversations.list({ types: "public_channel,private_channel", limit: 1000, }); const channels = (list.channels as any[]) || []; const found = channels.find( (c: any) => c.name === unsubNameFromRef, ); if (!found) { await respond(`Channel #${unsubNameFromRef} not found.`); return; } sourceId = found.id; } catch { await respond("Couldn't look up channels."); return; } } const sourceCh = await q.getChannel(db, sourceId); sourceName = sourceCh?.name || unsubNameFromRef || sourceId; const existingSubs = await q.getSubscriptionsBySubscriber( db, sourceChannelId, ); if (!existingSubs.some((s) => s.sourceChannelId === sourceId)) { await respond(`Not subscribed to #${sourceName}.`); return; } await q.removeSubscription(db, sourceChannelId, sourceId); cachedGraph = null; await logCommand(`unsub #${sourceName}`, db); await respond(`📰 Unsubscribed from #${sourceName}.`); };
switch (cmd) { case "pub": { if (subcmd === "off") { await doUnpub("pub off"); return; }
if (subcmd === "auto") { const target = arg.replace(/^<@(\w+)(\|[^>]*)?>$/, "$1").trim();
ch.enabled = true;
if (!target) { ch.autoApproveUsers = ["*"]; ch.manualUsers = []; await q.upsertChannel(db, ch); await logCommand("pub auto *", db); await respond( `📰 Auto-approve enabled for all users in #${ch.name}.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`, ); return; }
if (target !== userId) { try { const u = await slack.users.info({ user: target }); const isBot = (u.user as any)?.is_bot; if (!isBot) { if (!(await targetManager())) { await respond( "Only the channel creator can enable auto-approve for another non-bot user.", ); return; } } } catch { await respond("Couldn't look up that user."); return; } }
if (ch.autoApproveUsers.includes(target)) { await respond(`<@${target}> already has auto-approve.`); return; } ch.autoApproveUsers.push(target); ch.manualUsers = ch.manualUsers.filter((id) => id !== target); await q.upsertChannel(db, ch); await logCommand(`pub auto <@${target}>`, db); await respond( `📰 Auto-approve enabled for <@${target}> in #${ch.name}. Only their messages will be published.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`, ); return; }
if (subcmd === "link") { if (!(await targetManager())) { await respond("Only the channel creator can enable link mode."); return; } ch.enabled = true; const wantsOff = [ "off", "disable", "disabled", "0", "false", ].includes((arg || "").trim().toLowerCase()); ch.linkMode = !wantsOff; await q.upsertChannel(db, ch); const label = targetChannelId === sourceChannelId ? "this channel" : `#${ch.name}`; if (ch.linkMode) { await logCommand("pub link on", db); await respond( `🔗 Link mode enabled for ${label}. Approved messages will get an unfurlable permalink reply.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`, ); } else { await logCommand("pub link off", db); await respond(`🔗 Link mode disabled for ${label}.`); } return; }
if (subcmd === "manual") { if (!(await targetManager())) { await respond("Only the channel creator can enable manual mode."); return; } ch.enabled = true; const target = (arg || "").replace(/^<@(\w+)(\|[^>]*)?>$/, "$1").trim(); const label = targetChannelId === sourceChannelId ? "this channel" : `#${ch.name}`; if (target) { const wasAuto = ch.autoApproveUsers.includes(target); ch.autoApproveUsers = ch.autoApproveUsers.filter( (id) => id !== target, ); if (!ch.manualUsers.includes(target)) { ch.manualUsers.push(target); } await q.upsertChannel(db, ch); await logCommand(`pub manual <@${target}>`, db); const verb = wasAuto ? "moved to" : "added to"; await respond( `📰 <@${target}> ${verb} manual mode in ${label}. Their messages will get a Yep!/No prompt; other users' messages won't be published.`, ); return; } ch.autoApproveUsers = []; ch.manualUsers = []; await q.upsertChannel(db, ch); await logCommand("pub manual", db); await respond( `📰 Manual mode enabled for ${label}. Every message will get a Yep!/No prompt.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`, ); return; }
if (subcmd === "replies") { if (!(await targetManager())) { await respond( "Only the channel creator can toggle reply tracking.", ); return; } ch.trackReplies = !ch.trackReplies; ch.enabled = true; await q.upsertChannel(db, ch); await logCommand(`pub replies ${ch.trackReplies ? "on" : "off"}`, db); const label = targetChannelId === sourceChannelId ? "this channel" : `#${ch.name}`; if (ch.trackReplies) { await respond( `📰 Reply tracking enabled for ${label}. Thread replies will now be backed up with their reply relationship.`, ); } else { await respond(`📰 Reply tracking disabled for ${label}.`); } return; }
if (subcmd === "trigger") { if (!(await targetManager())) { await respond( "Only the channel creator can change the trigger mode.", ); return; } const mode = (arg || "").trim().toLowerCase(); if (mode !== "message" && mode !== "shortcut") { await respond( "Usage: \`/in pub trigger message\` | \`/in pub trigger shortcut\`", ); return; } ch.enabled = true; ch.triggerMode = mode; await q.upsertChannel(db, ch); const label = targetChannelId === sourceChannelId ? "this channel" : `#${ch.name}`; await logCommand(`pub trigger ${mode}`, db); if (mode === "shortcut") { await respond( `📰 Trigger mode set to shortcut for ${label}. Messages are only published via the "Add to feed" message shortcut, never automatically.`, ); } else { await respond( `📰 Trigger mode set to message for ${label}. Every message is handled per the auto/manual approval settings.`, ); } return; }
if (subcmd) { await respond( "Usage: \`/in pub\` | \`/in pub #channel\` | \`/in pub auto @user\` | \`/in pub manual [@user]\` | \`/in pub link\` | \`/in pub replies\` | \`/in pub trigger message|shortcut\` | \`/in pub off\`", ); return; } if (!(await targetManager())) { await respond("Only the channel creator can pub indigest."); return; } ch.enabled = true; ch.autoApproveUsers = []; ch.manualUsers = []; await q.upsertChannel(db, ch); await logCommand("pub", db); const label = targetChannelId === sourceChannelId ? "" : ` in #${ch.name}`; await respond( `📰 indigest pub'd${label}. New messages will get a prompt to add to the feed.\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`, ); return; }
case "unpub": { if (subcmd === "auto") { if (!ch.enabled) { await respond(`indigest is not pub'd in that channel.`); return; } const target = arg.replace(/^<@(\w+)(\|[^>]*)?>$/, "$1").trim(); if (!target) { ch.autoApproveUsers = []; await q.upsertChannel(db, ch); await logCommand("unpub auto *", db); await respond( `Auto-approve disabled for all users in #${ch.name}.`, ); return; } ch.autoApproveUsers = ch.autoApproveUsers.filter( (id) => id !== target, ); await q.upsertChannel(db, ch); await logCommand(`unpub auto <@${target}>`, db); await respond( `Auto-approve disabled for <@${target}> in #${ch.name}.`, ); return; }
if (subcmd) { await respond( "Usage: \`/in unpub\` | \`/in unpub #channel\` | \`/in unpub auto [@user]\` | \`/in pub off\`", ); return; } await doUnpub("unpub"); return; }
case "sub": { if (!(await targetManager())) { await respond( "Only the channel creator or workspace admin can subscribe channels.", ); return; }
if (subcmd === "off") { await doUnsub(arg); return; }
if (subcmd === "pfp" || subcmd === "name") { await handleSubOverride(db, slack, respond, logCommand, { subcmd, arg, subscriberChannelId: sourceChannelId, }); return; }
const subArg = [subcmd, arg].filter(Boolean).join(" "); if (!subArg) { await respond( "Usage: \`/in sub #channel\` | \`/in sub off #channel\` — subscribe/unsubscribe this channel to/from #channel.", ); return; }
const subChannelMatch = subArg.match( /^(<#(\w+)(\|[^>]*)?>|#(\S+))\s*$/, ); if (!subChannelMatch) { await respond( "Usage: \`/in sub #channel\` | \`/in sub off #channel\` — subscribe/unsubscribe this channel to/from #channel.", ); return; }
const subIdFromRef = subChannelMatch[2]; const subNameFromRef = subChannelMatch[4]; let sourceId: string; let sourceName: string;
if (subIdFromRef) { sourceId = subIdFromRef; } else { try { const list = await slack.conversations.list({ types: "public_channel,private_channel", limit: 1000, }); const channels = (list.channels as any[]) || []; const found = channels.find( (c: any) => c.name === subNameFromRef, ); if (!found) { await respond(`Channel #${subNameFromRef} not found.`); return; } sourceId = found.id; } catch { await respond("Couldn't look up channels."); return; } }
const sourceCh = await q.getChannel(db, sourceId); sourceName = sourceCh?.name || subNameFromRef || sourceId;
if (sourceId === sourceChannelId) { await respond("Can't subscribe a channel to itself."); return; }
if (!sourceCh || !sourceCh.enabled) { await respond( `#${sourceName} is not pub'd yet. It must be pub'd before other channels can subscribe to it.`, ); return; }
const existingSubs = await q.getSubscriptionsBySubscriber(db, sourceChannelId); if (existingSubs.some((s) => s.sourceChannelId === sourceId)) { await respond(`Already subscribed to #${sourceName}.`); return; }
await q.addSubscription(db, sourceChannelId, sourceId); cachedGraph = null; await logCommand(`sub #${sourceName}`, db); await respond( `📰 Subscribed to #${sourceName}. Messages pub'd there will be forwarded here.`, ); return; }
case "unsub": { if (!(await targetManager())) { await respond( "Only the channel creator or workspace admin can unsubscribe channels.", ); return; }
await doUnsub([subcmd, arg].filter(Boolean).join(" ")); return; }
case "status": { const chName = targetChannelId === sourceChannelId ? "" : `#${ch.name} `;
const perms: string[] = []; try { const conv = await slack.conversations.info({ channel: targetChannelId, }); const creator = (conv.channel as any)?.creator; if (creator === userId) perms.push("channel creator"); } catch { } if (lockdownUsers.includes(userId)) perms.push("lockdown override"); const permStr = perms.length > 0 ? `\nYour perms: ${perms.join(", ")}` : "\nYour perms: none (can view feeds only)";
if (ch.enabled) { let msg = `📰 indigest pub'd for ${chName}\nRSS: ${baseUrl}/feed/${targetChannelId}\nJSON: ${baseUrl}/feed/${targetChannelId}.json`; if (ch.linkMode) msg += `\nMode: link`; if (ch.triggerMode === "shortcut") { msg += `\nTrigger: shortcut only (no auto/manual publishing from messages)`; } if (ch.webhookUrl) msg += `\nWebhook: ${ch.webhookUrl}`; if (ch.autoApproveUsers.length > 0) { const autoLabel = ch.autoApproveUsers.includes("*") ? "all users" : ch.autoApproveUsers.map((id) => `<@${id}>`).join(", "); msg += `\nAuto-approve: ${autoLabel}`; } if (ch.manualUsers.length > 0) { msg += `\nManual-only: ${ch.manualUsers.map((id) => `<@${id}>`).join(", ")}`; } if (ch.metadataSchema) { try { const s = JSON.parse(ch.metadataSchema); msg += `\nMetadata schema: ${s.fields?.length || 0} field(s)`; } catch (err: any) { console.error("view_submission error:", err.message); } } if (ch.metadataRequired) msg += "\nMetadata: required"; if (ch.approvedPosters.length > 0) { if (ch.approvedPosters.includes("poster")) { const extraUsers = ch.approvedPosters.filter( (id) => id !== "poster", ); const extraLabel = extraUsers.length > 0 ? ` + ${extraUsers.map((id) => `<@${id}>`).join(", ")}` : ""; msg += `\nApprovals: poster mode${extraLabel}`; } else { msg += `\nApprovals: ${ch.approvedPosters.map((id) => `<@${id}>`).join(", ")}`; } } else { msg += `\nApprovals: poster + managers (default)`; } msg += `\nThread replies: ${ch.trackReplies ? "tracked" : "off"}`; const subs = await q.getSubscriptionsBySubscriber(db, targetChannelId); if (subs.length > 0) { const subNames = await Promise.all( subs.map(async (s) => { const sch = await q.getChannel(db, s.sourceChannelId); return sch ? `#${sch.name}` : s.sourceChannelId; }), ); msg += `\nSubscribed to: ${subNames.join(", ")}`; } const subscribers = await q.getSubscribersBySource(db, targetChannelId); if (subscribers.length > 0) { const subNames = await Promise.all( subscribers.map(async (s) => { const sch = await q.getChannel(db, s.subscriberChannelId); return sch ? `#${sch.name}` : s.subscriberChannelId; }), ); msg += `\nSubscribers: ${subNames.join(", ")}`; } msg += permStr; await respond(msg); return; } await respond( `indigest is not pub'd for ${chName}Run \`/in pub\` to start.${permStr}`, ); return; }
default: { if (!cmd) { await respond( "Commands:\n• \`pub [#channel]\` — enable indigest\n• \`unpub [#channel]\` — disable indigest (alias: \`pub off\`)\n• \`pub auto [@user]\` — auto-approve mode\n• \`pub manual [@user]\` — manual approve mode\n• \`pub replies\` — track thread replies\n• \`sub #channel\` — subscribe to another channel's feed\n• \`sub off #channel\` — unsubscribe from another channel's feed\n• \`sub pfp #source <value>\` | \`sub name #source <value>\` — set per-source profile overrides\n• \`unsub #channel\` — unsubscribe from a channel\n• \`perms @user\` — set who can approve messages\n• \`status [#channel]\` — show status\n• \`webhook <url>\` — set webhook\n• \`schema set <json>\` | \`schema get\` | \`schema clear\` | \`schema required on|off\`\n• \`prop set <json>\` | \`prop get\` | \`prop clear\` — channel properties", ); return; }
if (cmd === "webhook") { if (targetChannelId !== sourceChannelId) { await respond( "Webhook commands must be run from the target channel.", ); return; } if (!(await targetManager())) { await respond("Only the channel creator can configure webhooks."); return; } if (subcmd === "clear") { ch.webhookUrl = ""; await q.upsertChannel(db, ch); await logCommand("webhook clear", db); await respond("Webhook cleared."); return; }
const url = parts.slice(1).join(" "); if (!url.startsWith("http://") && !url.startsWith("https://")) { await respond("Invalid URL. Must start with http:// or https://"); return; } ch.webhookUrl = url; await q.upsertChannel(db, ch); await logCommand(`webhook set`, db); await respond(`📰 Webhook set to ${url}`); return; }
if (cmd === "auto" && subcmd === "list") { if (ch.autoApproveUsers.length === 0) { await respond(`No users have auto-approve in #${ch.name}.`); return; } if (ch.autoApproveUsers.includes("*")) { await respond(`Auto-approve in #${ch.name}: all users`); return; } const users = ch.autoApproveUsers .map((id) => `<@${id}>`) .join(", "); await respond(`Auto-approve in #${ch.name}: ${users}`); return; }
if (cmd === "schema") { if (subcmd === "get") { if (!ch.metadataSchema) { await respond( "No metadata schema configured for this channel.", ); return; } try { const pretty = JSON.stringify( JSON.parse(ch.metadataSchema), null, 2, ); await respond(`\`\`\`${pretty}\`\`\``); } catch { await respond(`Raw schema:\n${ch.metadataSchema}`); } return; }
if (subcmd === "clear") { ch.metadataSchema = ""; await q.upsertChannel(db, ch); await logCommand("schema clear", db); await respond("Metadata schema cleared."); return; }
if (subcmd === "required") { if (!(await targetManager())) { await respond( "Only the channel creator can change metadata requirements.", ); return; } const wantsOff = [ "off", "disable", "disabled", "0", "false", ].includes((arg || "").trim().toLowerCase()); ch.metadataRequired = !wantsOff; await q.upsertChannel(db, ch); await logCommand( `schema required ${ch.metadataRequired ? "on" : "off"}`, db, ); await respond( ch.metadataRequired ? "📝 Metadata is now required before messages can be forwarded." : "📝 Metadata is no longer required before forwarding.", ); return; }
if (subcmd === "set") { if (!arg) { await respond( "Usage: \`/in schema set <json>\` — provide a valid metadata schema JSON.", ); return; } try { const parsed = JSON.parse(arg); if (!parsed.fields || !Array.isArray(parsed.fields)) { await respond( 'Schema must have a \`fields\` array. Example: \`{"title": "Metadata", "fields": [{"action_id": "title", "label": "Title", "type": "plain_text_input"}]}\`', ); return; } ch.metadataSchema = JSON.stringify(parsed); await q.upsertChannel(db, ch); await logCommand("schema set", db); await respond( `📰 Metadata schema set with ${parsed.fields.length} field(s).`, ); } catch (e: any) { await respond(`Invalid JSON: ${e.message}`); } return; }
await respond( "Usage: \`/in schema set <json>\` | \`/in schema get\` | \`/in schema clear\` | \`/in schema required on|off\`", ); return; }
if (cmd === "prop") { if (subcmd === "get") { if (!ch.properties || ch.properties === "{}") { await respond("No properties configured for this channel."); return; } try { const pretty = JSON.stringify( JSON.parse(ch.properties), null, 2, ); await respond(`\`\`\`${pretty}\`\`\``); } catch { await respond(`Raw properties:\n${ch.properties}`); } return; }
if (subcmd === "clear") { ch.properties = "{}"; await q.upsertChannel(db, ch); await logCommand("prop clear", db); await respond("Channel properties cleared."); return; }
if (subcmd === "set") { if (!arg) { await respond( "Usage: \`/in prop set <json>\` — provide a valid properties JSON object.", ); return; } try { const parsed = JSON.parse(arg); if ( !parsed || typeof parsed !== "object" || Array.isArray(parsed) ) { await respond("Properties must be a JSON object."); return; } ch.properties = JSON.stringify(parsed); await q.upsertChannel(db, ch); await logCommand("prop set", db); await respond( `📰 Channel properties set: ${Object.keys(parsed).join(", ")}`, ); } catch (e: any) { await respond(`Invalid JSON: ${e.message}`); } return; }
await respond( "Usage: \`/in prop set <json>\` | \`/in prop get\` | \`/in prop clear\`", ); return; }
if (cmd === "perms") { if (subcmd === "get" || (!subcmd && !arg)) { if (ch.approvedPosters.length === 0) { await respond( "Default mode: only the original poster and channel managers can approve messages.", ); } else if (ch.approvedPosters.includes("poster")) { await respond( `Poster mode: the original poster of each message can approve it, plus channel managers.`, ); } else { const users = ch.approvedPosters .map((id) => `<@${id}>`) .join(", "); await respond( `Approved posters for #${ch.name}: ${users}\nPlus channel managers.`, ); } return; }
if (subcmd === "clear") { ch.approvedPosters = []; await q.upsertChannel(db, ch); await logCommand("perms clear", db); await respond( "Poster permissions cleared. Back to default: only the original poster and channel managers can approve.", ); return; }
// Parse user mentions and "poster" keyword from the full text const allParts = rest.split(/\s+/); const userIds: string[] = []; let posterMode = false; for (const part of allParts) { if (part.toLowerCase() === "poster") { posterMode = true; continue; } const mentionMatch = part.match(/^<@(\w+)(?:\|[^>]*)?>$/); if (mentionMatch) userIds.push(mentionMatch[1]); }
if (userIds.length === 0 && !posterMode) { await respond( "Usage: \`/in perms @user1 @user2\` — restrict to specific users\n\`/in perms poster\` — let each message's author approve their own\n\`/in perms get\` — view current permissions\n\`/in perms clear\` — restore defaults", ); return; }
ch.approvedPosters = posterMode ? ["poster", ...userIds] : userIds; await q.upsertChannel(db, ch); await logCommand(`perms ${posterMode ? "poster" : userIds.join(",")}`, db); if (posterMode && userIds.length > 0) { const users = userIds.map((id) => `<@${id}>`).join(", "); await respond( `📰 Poster permissions set for #${ch.name}: poster mode + ${users}`, ); } else if (posterMode) { await respond( `📰 Poster mode enabled for #${ch.name}. Each message's author can approve their own message.`, ); } else { const users = userIds.map((id) => `<@${id}>`).join(", "); await respond( `📰 Poster permissions set for #${ch.name}: ${users}`, ); } return; }
await respond( "Commands:\n• \`pub [#channel]\` — enable indigest\n• \`unpub [#channel]\` — disable indigest (alias: \`pub off\`)\n• \`pub auto [@user]\` — auto-approve mode\n• \`pub manual [@user]\` — manual approve mode\n• \`pub replies\` — track thread replies\n• \`sub #channel\` — subscribe to another channel's feed\n• \`sub off #channel\` — unsubscribe from another channel's feed\n• \`sub pfp #source <value>\` | \`sub name #source <value>\` — set per-source profile overrides\n• \`unsub #channel\` — unsubscribe from a channel\n• \`perms @user\` — set who can approve messages\n• \`status [#channel]\` — show status\n• \`webhook <url>\` — set webhook\n• \`schema set <json>\` | \`schema get\` | \`schema clear\`\n• \`prop set <json>\` | \`prop get\` | \`prop clear\` — channel properties", ); } } })(), );};
app.post("/slack", slackSlashCommand);app.post("/slack/", slackSlashCommand);
// === RSS Feed / JSON API ===app.get("/feed/:channelId", async (c) => { try { const env = c.env; const rawId = c.req.param("channelId")!; const wantsJson = rawId.endsWith(".json") || c.req.header("accept")?.includes("application/json"); const channelId = wantsJson ? rawId.replace(/\.json$/, "") : rawId; const db = getDb(getDbUrl(env)); const ch = await q.getChannel(db, channelId); if (!ch || !ch.enabled) { return c.json({ error: "not found", id: channelId }, 404); }
const identity = await resolveFeedIdentity(c, env); if (!canAccessFeed(ch, identity)) { return c.json( { error: "forbidden", message: "You don't have access to this feed" }, 403, ); }
const limit = Math.min(parseInt(c.req.query("limit") || "50") || 50, 200); const offset = parseInt(c.req.query("offset") || "0") || 0; const msgs = await q.getMessages(db, channelId, { limit, offset });
if (wantsJson) { return c.json(msgs); }
const baseUrl = env.BASE_URL || "http://localhost:8787"; const feed = new Feed({ title: `#${ch.name} — indigest`, link: `${baseUrl}/feed/${channelId}`, description: `Recent messages from #${ch.name}`, });
for (const m of msgs) { feed.addItem({ title: m.userName || "unknown", description: m.text.substring(0, 500), date: new Date(m.timestamp), guid: `${channelId}:${m.slackTs}`, link: `${baseUrl}/feed/${channelId}`, }); }
return c.text(feed.rss2(), 200, { "Content-Type": "application/rss+xml; charset=utf-8", }); } catch (err: any) { console.error("feed error:", err?.message || err); c.header("Retry-After", "2"); return c.text("Service Unavailable", 503); }});
// === REST API ===app.get("/api/messages", async (c) => { const env = c.env; const db = getDb(getDbUrl(env)); const channel = c.req.query("channel"); if (!channel) { return c.json({ error: "channel is required" }, 400); }
const ch = await q.getChannel(db, channel); if (!ch || !ch.enabled) { return c.json({ error: "channel not found or not enabled" }, 404); }
const identity = await resolveFeedIdentity(c, env); if (!canAccessFeed(ch, identity)) { return c.json( { error: "forbidden", message: "You don't have access to this channel" }, 403, ); }
const after = c.req.query("after"); const before = c.req.query("before"); const userId = c.req.query("userId"); const threadTs = c.req.query("threadTs"); const limit = Math.min(parseInt(c.req.query("limit") || "50") || 50, 10000); const page = parseInt(c.req.query("page") || "1") || 1; const offset = (page - 1) * limit;
const filterOpts = { after, before, userId, threadTs };
const [total, items] = await Promise.all([ q.getMessageCount(db, channel, filterOpts), q.getMessages(db, channel, { limit, offset, ...filterOpts }), ]);
return c.json({ data: items, pagination: { page, limit, total, total_pages: Math.ceil(total / limit) }, });});
app.get("/api/messages/:slackTs", async (c) => { const env = c.env; const db = getDb(getDbUrl(env)); const slackTs = decodeURIComponent(c.req.param("slackTs")); const channel = c.req.query("channel") || "";
if (!channel) { return c.json({ error: "channel is required" }, 400); }
const ch = await q.getChannel(db, channel); if (!ch || !ch.enabled) { return c.json({ error: "channel not found or not enabled" }, 404); }
const identity = await resolveFeedIdentity(c, env); if (!canAccessFeed(ch, identity)) { return c.json( { error: "forbidden", message: "You don't have access to this channel" }, 403, ); }
const msg = await q.getMessageBySlackTs(db, channel, slackTs); if (!msg) { return c.json({ error: "message not found" }, 404); }
return c.json({ data: msg });});
app.get("/api/channels", async (c) => { const env = c.env; const db = getDb(getDbUrl(env)); const channels = await q.listChannels(db); return c.json({ data: channels });});
app.get("/api/channels/:id", async (c) => { const env = c.env; const db = getDb(getDbUrl(env)); const id = decodeURIComponent(c.req.param("id")); const ch = await q.getChannel(db, id); if (!ch) return c.json({ error: "channel not found" }, 404); return c.json({ data: ch });});
// --- Graph API (for React Flow diagram) ---let cachedGraph: { data: any; at: number } | null = null;const GRAPH_CACHE_TTL = 60_000; // 1 minute
app.get("/api/graph", async (c) => { const env = c.env;
if (cachedGraph && Date.now() - cachedGraph.at < GRAPH_CACHE_TTL) { return c.json(cachedGraph.data); }
const db = getDb(getDbUrl(env));
const { channels, subscriptions, subscriberChannels } = await q.getGraphData(db);
// Index channels by ID for fast lookup const channelMap = new Map<string, StoreChannel>(); for (const ch of channels) channelMap.set(ch.id, ch); for (const ch of subscriberChannels) channelMap.set(ch.id, ch);
// Index subscriptions by source channel const subsBySource = new Map<string, StoreSubscription[]>(); for (const sub of subscriptions) { const list = subsBySource.get(sub.sourceChannelId) || []; list.push(sub); subsBySource.set(sub.sourceChannelId, list); }
const nodes: Array<{ id: string; type: string; label: string; data: Record<string, any>; }> = []; const edges: Array<{ id: string; source: string; target: string; label?: string; animated: boolean; }> = [];
for (const ch of channels) { nodes.push({ id: `ch:${ch.id}`, type: "channel", label: `#${ch.name || ch.id}`, data: { channelId: ch.id, name: ch.name, webhookUrl: ch.webhookUrl, metadataSchema: ch.metadataSchema, metadataRequired: ch.metadataRequired, trackReplies: ch.trackReplies, approvedPosters: ch.approvedPosters, autoApproveUsers: ch.autoApproveUsers?.join(", ") || "", enabled: ch.enabled, linkMode: ch.linkMode, triggerMode: ch.triggerMode, createdAt: ch.createdAt, }, }); nodes.push({ id: `rss:${ch.id}`, type: "feed", label: "RSS Feed", data: { feedType: "rss", channelId: ch.id }, }); edges.push({ id: `e-rss-${ch.id}`, source: `ch:${ch.id}`, target: `rss:${ch.id}`, animated: true, }); nodes.push({ id: `api:${ch.id}`, type: "feed", label: "API Feed", data: { feedType: "api", channelId: ch.id }, }); edges.push({ id: `e-api-${ch.id}`, source: `ch:${ch.id}`, target: `api:${ch.id}`, animated: true, }); if (ch.webhookUrl) { nodes.push({ id: `wh:${ch.id}`, type: "feed", label: "Webhook", data: { feedType: "webhook", channelId: ch.id, url: ch.webhookUrl }, }); edges.push({ id: `e-wh-${ch.id}`, source: `ch:${ch.id}`, target: `wh:${ch.id}`, animated: true, }); }
const subs = subsBySource.get(ch.id) || []; for (const sub of subs) { const subCh = channelMap.get(sub.subscriberChannelId); const subLabel = subCh ? `#${subCh.name || subCh.id}` : sub.subscriberChannelId; if (!nodes.find((n) => n.id === `sub:${sub.subscriberChannelId}`)) { nodes.push({ id: `sub:${sub.subscriberChannelId}`, type: "subscriber", label: subLabel, data: { channelId: sub.subscriberChannelId, name: subCh?.name, webhookUrl: subCh?.webhookUrl, metadataSchema: subCh?.metadataSchema, metadataRequired: subCh?.metadataRequired, trackReplies: subCh?.trackReplies, approvedPosters: subCh?.approvedPosters, autoApproveUsers: subCh?.autoApproveUsers?.join(", ") || "", enabled: subCh?.enabled, linkMode: subCh?.linkMode, createdAt: subCh?.createdAt, }, }); } edges.push({ id: `e-sub-${ch.id}-${sub.subscriberChannelId}`, source: `ch:${ch.id}`, target: `sub:${sub.subscriberChannelId}`, label: "[sub]", animated: true, }); } }
const result = { data: { nodes, edges } }; cachedGraph = { data: result, at: Date.now() }; return c.json(result);});
export default app;