Files
accounted/extensions/general/whatsapp-inbox/lib/messages.ts
T
c84f951a5c fix(whatsapp-inbox): name both ways to route receipts for multi-company senders (#1424)
Field feedback after the first live receipt: the link confirmation told a
multi-company sender only to set a default company in the panel, so the
per-receipt path looked unsupported even though it is the one that
actually runs when no default is set. Now it names both: set a default,
or send one receipt at a time and answer the company question after each.

The guidance also moves to its own paragraph. Run together with the AI
disclosure it read as one sentence, which is how a real user came away
believing the bot had called itself a "mänsklig AI-assistent".

New copy test asserts the promise (both options present, disclosure kept
in its own paragraph, single-company message unchanged) rather than the
exact wording, so a rewrite stays free but a dropped option fails.
Mutation-checked against the previous copy.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:43:37 +02:00

319 lines
16 KiB
TypeScript

/**
* Outbound bot copy for the WhatsApp channel (approved conversation spec
* M1-M18; M5-M10 are the PR4 conversation layer).
*
* Server-side constants, NOT messages/*.json: these strings are sent from
* webhook/worker contexts where no next-intl session exists (same pattern as
* lib/email/reminder-templates.ts). Domain terms (Underlag, verifikation) stay
* Swedish in both locales per the i18n rules. WhatsApp formatting subset only
* (*bold*, _italic_); never em/en dashes, per repo hard rule.
*
* Locale: the chat has no session, and no per-user locale is stored server-side
* today, so dispatch always sends 'sv'. The 'en' variants exist so the wiring
* is a one-line change once a stored locale exists.
*/
export type BotLocale = 'sv' | 'en'
export interface LinkedTemplateArgs {
companyName?: string | null
companyCount?: number
merchant?: string | null
amount?: string | null
date?: string | null
minutes?: number
}
/** Stable ids stamped into whatsapp_messages.raw_payload.template on every send
* (throttle checks and tests key on these, never on the copy). */
export const TEMPLATE = {
m1Unlinked: 'm1_unlinked',
m2BadCode: 'm2_bad_code',
m3Linked: 'm3_linked',
m4Ack: 'm4_ack',
m4AckEmpty: 'm4_ack_empty',
m4Duplicate: 'm4_duplicate',
m5BurstAck: 'm5_burst_ack',
m6CompanyQuestion: 'm6_company_question',
m6CompanyConfirm: 'm6_company_confirm',
m6CompanyRetry: 'm6_company_retry',
m6BytPin: 'm6_byt_pin',
m7Representation: 'm7_representation',
m8RepConfirmed: 'm8_rep_confirmed',
m8RepPartial: 'm8_rep_partial',
m8RepDenied: 'm8_rep_denied',
m9Resend: 'm9_resend',
m9NoteSaved: 'm9_note_saved',
m10Context: 'm10_context',
m10ContextConfirm: 'm10_context_confirm',
m11Stop: 'm11_stop',
m12Start: 'm12_start',
m13Help: 'm13_help',
m14Voice: 'm14_voice',
m15Unsupported: 'm15_unsupported',
m16Fallback: 'm16_fallback',
m17RateLimited: 'm17_rate_limited',
m17RateLimitedDay: 'm17_rate_limited_day',
m18Error: 'm18_error',
} as const
export type TemplateId = (typeof TEMPLATE)[keyof typeof TEMPLATE]
const SV = {
m1Unlinked: () =>
'Hej! Det här är *Accounteds kvittomottagning*, en automatisk AI-tjänst.\n\n' +
'För att koppla ditt nummer: öppna Accounted, gå till *Inställningar -> WhatsApp* och skicka den 6-siffriga koden här.\n\n' +
'Skriv *hjälp* för att nå en människa.',
m2BadCode: () =>
'Koden stämmer inte eller har gått ut. Hämta en ny under *Inställningar -> WhatsApp* i Accounted och skicka den här inom 10 minuter.',
m3Linked: ({ companyName, companyCount = 1 }: LinkedTemplateArgs) => {
const intro = companyName
? `Klart! Ditt nummer är kopplat till *${companyName}*.`
: 'Klart! Ditt nummer är kopplat till *Accounted*.'
const tips =
'Skicka kvitton hit som foto eller PDF så lägger jag dem i *Underlag* i Accounted. ' +
'Två tips: skicka som _dokument_ (gem-ikonen) för bästa skärpa, och en fil per kvitto. Flersidiga kvitton skickas som PDF.'
const outro =
'Jag är en AI-assistent. Skriv *hjälp* för mänsklig support, *stopp* för att koppla från.'
// Two real ways to route a receipt, and the old copy named only one,
// which read as if the per-receipt path did not exist. Own paragraph:
// run together with the outro it read as a single sentence.
const multi =
companyCount > 1
? `Du är med i ${companyCount} företag i Accounted. Välj antingen ett standardföretag i panelen, ` +
'eller skicka ett kvitto i taget så frågar jag vilket företag det gäller direkt efter varje kvitto.\n\n'
: ''
return `${intro}\n\n${tips}\n\n${multi}${outro}`
},
m4Ack: ({ merchant, amount, date }: LinkedTemplateArgs) => {
const who = merchant ? `${merchant}, ` : ''
const when = date ? ` (${date})` : ''
return `*Kvitto mottaget:* ${who}${amount}${when}. Ligger i Underlag, granska och bokför i appen.`
},
m4AckEmpty: () =>
'*Kvitto mottaget.* Jag kunde inte läsa av beloppet, komplettera i appen under Underlag.',
m4Duplicate: () => 'Det kvittot finns redan i Underlag.',
m5BurstAck: ({ lines }: { lines: string[] }) =>
`Fick *${lines.length}* kvitton:\n` +
lines.map((line, i) => `${i + 1}. ${line}`).join('\n') +
'\nAlla ligger i Underlag i Accounted.',
// The receipt is not ingested yet when this is asked (the item is created
// only after the company answer), so merchant/amount are unknown here.
m6CompanyQuestion: ({ count = 1 }: { count?: number }): string =>
count > 1 ? 'Vilket företag gäller kvittona?' : 'Vilket företag gäller kvittot?',
m6CompanyQuestionNumbered: ({ count = 1, options }: { count?: number; options: string[] }) =>
(count > 1 ? 'Vilket företag gäller kvittona?' : 'Vilket företag gäller kvittot?') +
'\n\n' +
options.map((name, i) => `${i + 1}. ${name}`).join('\n') +
'\n\nSvara med en siffra.',
m6CompanyConfirm: ({ companyName }: { companyName: string }) =>
`*${companyName}*, noterat. Jag minns valet i 8 timmar (skriv *byt* för att ändra).`,
// Typo or a typed company name while the question is open: repeat the list
// instead of the M16 lecture or silence.
m6CompanyRetry: ({ options }: { options: string[] }) =>
`Jag känner inte igen svaret. Svara med en siffra 1-${options.length}:\n\n` +
options.map((name, i) => `${i + 1}. ${name}`).join('\n'),
m6BytPin: () => 'Okej, jag frågar vilket företag nästa gång du skickar ett kvitto.',
m7Representation: ({ ref }: { ref?: string | null } = {}) =>
`${ref ?? 'Det ser ut som representation.'} För att avdraget ska hålla behöver verifikationen *vilka som deltog* (namn + företag) och *syftet*.\n\n` +
'Svara i ett meddelande, t.ex: _Lunch med Anna Berg (Volvo) och mig, uppföljning av avtal_. Svara *nej* om det inte är representation.',
m8RepConfirmed: ({ participants, purpose }: { participants: string; purpose?: string | null }) =>
`Tack! Noterat: ${participants}${purpose ? ` · Syfte: ${purpose}` : ''}. Följer med när kvittot bokförs.`,
m8RepPartial: () =>
'Tack! Jag har sparat ditt svar som anteckning på kvittot. Kolla att deltagare och syfte kom med när du bokför i appen.',
m8RepDenied: () => 'Okej, ingen representation. Kvittot ligger i Underlag som vanligt.',
m9Resend: ({ ordinal }: { ordinal?: number | null } = {}) =>
ordinal != null
? `Kvitto ${ordinal} blev för lågupplöst för att läsas av, WhatsApp komprimerar foton hårt. Skicka gärna det igen som *dokument* (gem-ikonen -> _Dokument_) så behålls full skärpa.`
: 'Bilden blev för lågupplöst för att läsas av, WhatsApp komprimerar foton hårt.\n\n' +
'Skicka gärna samma kvitto igen som *dokument*: gem-ikonen -> _Dokument_ -> välj bilden. Då behålls full skärpa. Kvittot ligger kvar i Underlag så länge.',
// Text arrived while a re-send question is open: the words cannot replace
// the file, so they are kept as a note and the question stays open.
m9NoteSaved: () =>
'Tack! Sparat som anteckning på kvittot. Skicka gärna samma kvitto igen som *dokument* (gem-ikonen -> _Dokument_) så kan jag läsa av beloppen.',
m10Context: ({ ordinal }: { ordinal?: number | null } = {}) =>
ordinal != null
? `Kvitto ${ordinal}: jag kunde inte läsa av allt. Vad gäller köpet? Skriv en kort rad (t.ex. _taxi till kundmöte, 240 kr_) så sparar jag det på kvittot.`
: '*Fil mottagen*, men jag kunde inte läsa av allt. Vad gäller köpet? Skriv en kort rad (t.ex. _taxi till kundmöte, 240 kr_) så sparar jag det på kvittot.',
m10ContextConfirm: () => 'Tack! Sparat som anteckning på kvittot.',
// Honest wording: `stopp` PAUSES the channel (muted_at), it does not revoke
// the binding. Actually disconnecting is the settings panel's "Koppla från",
// and the panel shows the paused state as "Pausad": the copy must match.
m11Stop: () =>
'Okej, jag pausar och slutar svara här. Numret är kvar kopplat men jag tar inte emot något mer. Dina underlag i Accounted påverkas inte.\n\n' +
'Skicka *start* när du vill aktivera igen, eller koppla från numret helt under *Inställningar -> WhatsApp* i Accounted.',
m12Start: () => 'Välkommen tillbaka! Ditt nummer är aktivt igen, skicka kvitton när du vill.',
m13Help: () =>
'Du når en människa på *support@accounted.se*, vi svarar vardagar, oftast samma dag. Skriv gärna vilket företag det gäller.\n\n' +
'Här i chatten tar jag annars emot kvitton och underlag (foto eller PDF).',
m14Voice: () =>
'Jag kan inte lyssna på röstmeddelanden ännu. Skicka kvittot som foto eller PDF, eller skriv en kort rad text.',
m15Unsupported: () =>
'Jag kan bara ta emot bilder och PDF-dokument (bild max 5 MB). Skicka kvittot som foto eller PDF.',
m16Fallback: () =>
'Jag är en automatisk kvittomottagning och kan inte svara på frågor här i chatten. Skicka ett kvitto (foto/PDF) så tar jag hand om det, eller skriv *hjälp* för mänsklig support.',
m17RateLimited: ({ minutes = 10 }: LinkedTemplateArgs) =>
`Det blev många filer på kort tid, jag pausar mottagningen en stund. Försök igen om cirka ${minutes} minuter.`,
// The 500/day company quota resets at midnight, so "cirka 10 minuter" is
// simply wrong there: never promise a minute count we cannot keep.
m17RateLimitedDay: () =>
'Dagens gräns för mottagna filer är nådd för det här företaget. Jag tar emot fler i morgon, eller ladda upp direkt i appen under *Underlag* så kommer de in nu.',
m18Error: () =>
'Något gick fel när jag tog emot filen. Försök igen om en stund, eller ladda upp kvittot direkt i appen under *Underlag*. Skriv *hjälp* om det fortsätter.',
}
const EN: typeof SV = {
m1Unlinked: () =>
'Hi! This is *Accounted receipt intake*, an automated AI service.\n\n' +
'To link your number: open Accounted, go to *Settings -> WhatsApp* and send the 6-character code here.\n\n' +
'Type *hjälp* to reach a human.',
m2BadCode: () =>
'That code is wrong or has expired. Get a new one under *Settings -> WhatsApp* in Accounted and send it here within 10 minutes.',
m3Linked: ({ companyName, companyCount = 1 }: LinkedTemplateArgs) => {
const intro = companyName
? `Done! Your number is linked to *${companyName}*.`
: 'Done! Your number is linked to *Accounted*.'
const tips =
'Send receipts here as a photo or PDF and I will file them under *Underlag* in Accounted. ' +
'Two tips: send as a _document_ (the paperclip icon) for full sharpness, and one file per receipt. Multi-page receipts go as PDF.'
const outro =
'I am an AI assistant. Type *hjälp* for human support, *stopp* to disconnect.'
const multi =
companyCount > 1
? `You belong to ${companyCount} companies in Accounted. Either pick a default company in the panel, ` +
'or send one receipt at a time and I will ask which company it belongs to right after each one.\n\n'
: ''
return `${intro}\n\n${tips}\n\n${multi}${outro}`
},
m4Ack: ({ merchant, amount, date }: LinkedTemplateArgs) => {
const who = merchant ? `${merchant}, ` : ''
const when = date ? ` (${date})` : ''
return `*Receipt received:* ${who}${amount}${when}. It is in Underlag, review and book it in the app.`
},
m4AckEmpty: () =>
'*Receipt received.* I could not read the amount, complete it in the app under Underlag.',
m4Duplicate: () => 'That receipt is already in Underlag.',
m5BurstAck: ({ lines }: { lines: string[] }) =>
`Got *${lines.length}* receipts:\n` +
lines.map((line, i) => `${i + 1}. ${line}`).join('\n') +
'\nAll of them are in Underlag in Accounted.',
m6CompanyQuestion: ({ count = 1 }: { count?: number }) =>
count > 1
? 'Which company do the receipts belong to?'
: 'Which company does the receipt belong to?',
m6CompanyQuestionNumbered: ({ count = 1, options }: { count?: number; options: string[] }) =>
(count > 1
? 'Which company do the receipts belong to?'
: 'Which company does the receipt belong to?') +
'\n\n' +
options.map((name, i) => `${i + 1}. ${name}`).join('\n') +
'\n\nReply with a number.',
m6CompanyConfirm: ({ companyName }: { companyName: string }) =>
`*${companyName}*, noted. I will remember the choice for 8 hours (type *byt* to change).`,
m6CompanyRetry: ({ options }: { options: string[] }) =>
`I did not recognise that answer. Reply with a number 1-${options.length}:\n\n` +
options.map((name, i) => `${i + 1}. ${name}`).join('\n'),
m6BytPin: () => 'Okay, I will ask which company the next time you send a receipt.',
m7Representation: ({ ref }: { ref?: string | null } = {}) =>
`${ref ?? 'This looks like representation.'} For the deduction to hold, the verifikation needs *who attended* (name + company) and *the purpose*.\n\n` +
'Reply in one message, e.g: _Lunch with Anna Berg (Volvo) and me, contract follow-up_. Reply *nej* if it is not representation.',
m8RepConfirmed: ({ participants, purpose }: { participants: string; purpose?: string | null }) =>
`Thanks! Noted: ${participants}${purpose ? ` · Purpose: ${purpose}` : ''}. It follows the receipt when it is booked.`,
m8RepPartial: () =>
'Thanks! I saved your reply as a note on the receipt. Check that attendees and purpose came through when you book it in the app.',
m8RepDenied: () => 'Okay, no representation. The receipt is in Underlag as usual.',
m9Resend: ({ ordinal }: { ordinal?: number | null } = {}) =>
ordinal != null
? `Receipt ${ordinal} came through at too low a resolution to read, WhatsApp compresses photos hard. Please send it again as a *document* (paperclip icon -> _Document_) to keep full sharpness.`
: 'The image came through at too low a resolution to read, WhatsApp compresses photos hard.\n\n' +
'Please send the same receipt again as a *document*: paperclip icon -> _Document_ -> pick the image. That keeps full sharpness. The receipt stays in Underlag meanwhile.',
m9NoteSaved: () =>
'Thanks! Saved as a note on the receipt. Please send the same receipt again as a *document* (paperclip icon -> _Document_) so I can read the amounts.',
m10Context: ({ ordinal }: { ordinal?: number | null } = {}) =>
ordinal != null
? `Receipt ${ordinal}: I could not read everything. What was the purchase? Write a short line (e.g. _taxi to client meeting, 240 kr_) and I will save it on the receipt.`
: '*File received*, but I could not read everything. What was the purchase? Write a short line (e.g. _taxi to client meeting, 240 kr_) and I will save it on the receipt.',
m10ContextConfirm: () => 'Thanks! Saved as a note on the receipt.',
m11Stop: () =>
'Okay, I am pausing and will stop replying here. Your number stays linked but I will not receive anything more. Your documents in Accounted are not affected.\n\n' +
'Send *start* whenever you want to activate again, or disconnect the number entirely under *Settings -> WhatsApp* in Accounted.',
m12Start: () => 'Welcome back! Your number is active again, send receipts whenever you like.',
m13Help: () =>
'You reach a human at *support@accounted.se*, we reply on weekdays, usually the same day. Please mention which company it concerns.\n\n' +
'Here in the chat I otherwise receive receipts and documents (photo or PDF).',
m14Voice: () =>
'I cannot listen to voice messages yet. Send the receipt as a photo or PDF, or write a short line of text.',
m15Unsupported: () =>
'I can only receive images and PDF documents (image max 5 MB). Send the receipt as a photo or PDF.',
m16Fallback: () =>
'I am an automated receipt intake and cannot answer questions here in the chat. Send a receipt (photo/PDF) and I will handle it, or type *hjälp* for human support.',
m17RateLimited: ({ minutes = 10 }: LinkedTemplateArgs) =>
`That was a lot of files in a short time, I am pausing intake for a bit. Try again in about ${minutes} minutes.`,
m17RateLimitedDay: () =>
'This company has reached its daily limit for received files. I will accept more tomorrow, or upload directly in the app under *Underlag* to get them in now.',
m18Error: () =>
'Something went wrong receiving the file. Try again in a moment, or upload the receipt directly in the app under *Underlag*. Type *hjälp* if it keeps happening.',
}
const COPY: Record<BotLocale, typeof SV> = { sv: SV, en: EN }
export function botCopy(locale: BotLocale = 'sv'): typeof SV {
return COPY[locale] ?? SV
}