Skip to content

docs(readme): expand sponsor section with B2B framing and tier ladder#50

Open
arunrajiah wants to merge 26 commits into
mainfrom
arunrajiah-patch-1
Open

docs(readme): expand sponsor section with B2B framing and tier ladder#50
arunrajiah wants to merge 26 commits into
mainfrom
arunrajiah-patch-1

Conversation

@arunrajiah
Copy link
Copy Markdown
Owner

Adds the audit-pattern sponsor pitch on top of the existing links section. The new section adds a procurement anchor (a $50/mo team sponsorship is roughly the cost of a single Odoo Enterprise seat), four concrete roadmap items sponsorship currently funds (write-tool coverage in Invoices/Inventory/Purchase, OCA submission, Redis-backed rate limiter, faster issue turnaround), and a three-tier ladder ($10/$50/$200). The existing Sponsor/Feedback/Bug/Security/Email links are preserved.

Docs-only change. No code, dependencies, or behavior touched.

arunrajiah and others added 26 commits April 25, 2026 11:25
feat(listing): rewrite index.html with inline styles and table layouts
This release closes the issues reported on r/Odoo (jeconti's audit) and
is the recommended upgrade for everyone running 17.0.6.0.0 or earlier.

1. WhatsApp webhook now verifies Meta's X-Hub-Signature-256 (HMAC-SHA256
   of the raw body, keyed with the App Secret). Without a configured
   secret OR a valid signature, the endpoint returns 403. Closes the
   impersonation hole that let any internet attacker who guessed the
   webhook URL act as any linked WhatsApp user.

2. Telegram webhook secret is now mandatory. The Register-webhook action
   auto-generates a 32-byte URL-safe secret if one isn't set and registers
   it with Telegram. The endpoint rejects any request whose
   X-Telegram-Bot-Api-Secret-Token header doesn't match.

3. Confirmation callbacks are bound to a per-write nonce. Each staged
   write generates a fresh secrets.token_urlsafe(12) stored on the
   session row; the Yes/No payload carries it as confirm:yes:<nonce>
   and is verified in constant time before execution. Defends against
   prompt-injection that tries to swap the staged tool between staging
   and the user clicking Yes.

4. Magic-link tokens moved to a dedicated odoopilot.link.token model
   that stores SHA-256 digests only and consumes the row atomically
   on use. Hourly cron garbage-collects expired entries. Re-issuing
   a token for the same chat invalidates the previous one.

Migration: post-migration.py clears in-flight pending writes (pre-7.0
confirmations didn't carry a nonce) and removes legacy link-token
system parameters. Operators must re-register the Telegram webhook
once and paste their Meta App Secret into Settings.

Other changes:
- Tests: tests/test_security.py covers HMAC verify, nonce rotation,
  hashed token storage, and single-use consumption.
- ACL: new odoopilot.link.token row in ir.model.access.csv.
- Cron: ir_cron_gc_link_tokens runs hourly.
- Listing/README: removed all "vs competitor" / pricing-comparison
  framing per project direction; replaced with a Security model
  section and feature-only descriptions.

Version bumped 17.0.6.0.0 -> 17.0.7.0.0.
CI's ruff format --check step was failing on the 4 files added/modified
in 17.0.7.0.0. No behavioural change -- pure formatting.
Establishes a coordinated-disclosure process via GitHub Security
Advisories, defines supported versions per Odoo series, lists what
is in/out of scope, and documents the addon's threat model in a
single paragraph. Credits jeconti for the 2026-04-25 public audit.

The motivation is to give future security researchers a private
reporting path so production Odoo operators are not exposed to
unpatched issues during disclosure.
Five additional findings surfaced after shipping 17.0.7.0.0:

1. Magic-link CSRF (High) — controller now splits into GET (renders a
   CSRF-protected confirmation form) and POST (consumes the token, does
   the link write). Cross-site image GETs no longer cause state changes.
   New `peek()` on the link.token model previews without deleting.

2. Identity hijack (High) — refuse to overwrite existing.user_id when it
   belongs to a different Odoo user, both at GET preview and POST commit.

3. Wildcard write-target hijack (High) — services/tools.py now has
   `preflight_write` that resolves the target before staging, stores
   res_id (not name) in pending_args, and renders the resolved
   display_name in the confirmation. Wildcard-only / overly-short names
   are rejected. CRM stage lookups are scoped to the lead's sales team.
   The five write executors accept resolved IDs.

4. Cost-amplification DoS (Medium) — new services/throttle.py with a
   sliding-window per-(channel, chat_id) rate limiter and a bounded
   thread pool; replaces unbounded daemon-thread spawn. Configurable via
   ir.config_parameter (rate_limit_per_hour, rate_limit_window_seconds,
   worker_pool_size).

5. Webhook delivery non-idempotency (Medium) — new
   odoopilot.delivery.seen model with UNIQUE(channel, external_id);
   the controller dedups on Telegram update_id and WhatsApp
   messages[].id before submitting work. Hourly cron GCs old rows.

Regression tests added in tests/test_security.py for every fix.
…, hygiene

Closes the four lower-impact findings from the post-17.0.7 internal review.
None is independently exploitable — these are hardening / hygiene fixes
that remove easy mistakes a future contributor could make.

* Bot token scrub in services/telegram.py: requests exception strings
  often include the failing URL, which contains the bot token. _scrub
  redacts the token before any string is passed to the logger; _call now
  logs only the scrubbed message and the exception type.

* WhatsApp verify_token compare: switched from == to hmac.compare_digest
  in controllers/main.py:whatsapp_verify. Verify tokens are low-value
  but the change is free.

* Trust-boundary rename: the env parameter passed into the dispatch
  helpers and downstream _handle_* helpers was actually a SUPERUSER_ID
  Environment. Renamed to sudo_env throughout, and added docstrings to
  both dispatchers explaining the contract: sudo_env is for the
  unavoidable privileged lookups; business-data access must use
  sudo_env(user=identity.user_id.id).

* Defensive else for malformed callback payloads in _handle_confirmation
  and _handle_whatsapp_confirmation. Behaviour was already correct
  (silent no-op) but now logs at WARNING and returns explicitly.

Regression tests for the scrub and compare_digest semantics added in
tests/test_security.py.
Inspecting the live page source revealed the App Store sanitiser does
two things our previous design relied on:

1. Strips background and background-color from inline styles entirely.
   This made the dark hero (and white text inside it) invisible and
   killed the colour-fill cards across every section.

2. Rewrites <a href="...">CustomText</a> into <span href="...">CustomText
   </span>, which is non-clickable HTML. Every styled link in the listing
   was broken; the user reported this as "links are not working".

Plain URL text is auto-linked by a separate pass that emits a fresh
<a rel="nofollow"> which DOES survive the sanitiser.

This rewrite:

* Removes every background and background-color declaration. Visual
  hierarchy now comes from borders, text colour, and padding only.
* Removes every white text colour. Default text is dark slate (#1e293b)
  on whatever background the App Store renders.
* Replaces every styled CTA <a> with plain URL text. Sponsor button,
  GitHub button, resource links, footer credit -- all now render as
  auto-linked URLs with a leading label like "Get OdooPilot ->".
* Rebuilds the demo conversation panes with text + coloured left
  borders instead of background-filled chat bubbles.
* Replaces the dark teal Resources block and dark purple Sponsor block
  with bordered cards using accent colours on text and borders only.

A header comment in the file documents the sanitiser's behaviour so
future edits don't reintroduce the same problems.

Verified zero non-ASCII bytes; all special characters as HTML entities.
The Settings -> OdooPilot page now shows a richer footer with explicit
links for the things users actually want to do after install:

* Sponsor on GitHub          -> https://github.com/sponsors/arunrajiah
* Feedback & ideas           -> github.com/arunrajiah/odoopilot/discussions/new?category=ideas
* Report a bug               -> github.com/arunrajiah/odoopilot/issues/new/choose
* Report a security issue    -> github.com/arunrajiah/odoopilot/security/advisories/new

Plus a thin row of quick-reference links to the source code, README,
CHANGELOG and SECURITY.md.

Each link is a real <a target="_blank" rel="noopener noreferrer"> -
this is the in-Odoo settings view, not the App Store description, so
the App Store sanitiser does not apply here. Native Odoo HTML rendering
preserves both the styling and the click handler.
The pitch needed a sharper frame: this is for your internal team, not
your customers, and the killer use case is "your employees use Odoo
without logging in to Odoo." Every Odoo deployment has the same gap --
non-power-users avoid the desktop UI for routine tasks (apply for leave,
update the pipeline after a sales call, validate a stock transfer at
the dock), so data goes stale and approvals stall. OdooPilot meets
employees where they already are: their phone, in chat, in their language.

Changes across three surfaces, all telling the same story:

* odoopilot/static/description/index.html
  - Hero rewritten: "Your team uses Odoo -- without logging in to Odoo"
    is the headline. Clarifying note: "For your internal team. Not for
    your customers."
  - New "A day in the life" section with four concrete employee
    scenarios (new hire applies for leave; manager approves; sales rep
    updates pipeline from the field; warehouse picker validates transfer
    at the dock).
  - New "What OdooPilot is not" callout: not a customer chatbot, not
    a permission bypass, not a public widget. Each linked user is an
    Odoo user with audit-trail accountability.
  - Personas reframed from role labels (Executives / Devs) to
    employee-centric (Every employee / Managers on the move / Field &
    warehouse / IT & developers).
  - New "Odoo adoption problem -- solved" before/after comparison
    table that names the friction directly.

* __manifest__.py
  - Module name and summary lead with the new positioning. Long
    description preamble explains the gap and the solution. Keywords
    expanded with employee-self-service / mobile / approval / leave-
    request terms.

* README.md
  - Top section reframed to match. Demo conversation switched from a
    generic task-query example to the leave-request / approval flow,
    which is the canonical use case the new pitch leads with.
Bundles three already-shipped changes into a tagged release:

* feat(settings): four-card community panel (Sponsor / Feedback / Bug /
  Security) at the bottom of Settings -> OdooPilot, with a quick-
  reference row for source / docs / changelog / security policy.
* docs(listing): redesign for the App Store HTML sanitiser, which
  strips background declarations and rewrites <a> to <span href>.
  Listing now uses bordered cards (no fills) and plain URL text (the
  auto-linker emits clickable <a> that survive the sanitiser).
* docs: reposition the pitch around the killer use case -- your team
  uses Odoo without logging in to Odoo. Updated listing, manifest and
  README to lead with this frame.

No code changes; manifest version bumped from 17.0.9.0.0 to 17.0.10.0.0.
…ter)

Three engineering hygiene items shipped together. No code paths changed;
upgrade is optional.

* Banner regenerated to match the new pitch. The marketplace banner now
  leads with "Your team uses Odoo - without logging in to Odoo." plus
  two phone notifications showing the leave request -> approval flow
  on WhatsApp + Telegram. Source HTML preserved at
  static/description/banner_source.html and rebuilt via the new
  scripts/render_banner.py (Playwright + chromium headless).

* CI now runs bandit and semgrep on every push and PR. After three
  security releases in a week this is the right insurance - the next
  class of issue gets surfaced automatically rather than in a public
  audit. Both scanners run with continue-on-error: true while we tune
  the rule set; we'll tighten to a hard gate once the noise floor is
  known. Bandit: 0 medium/high. Semgrep p/python: 0 blocking.

* New CI job runs scripts/check_listing_rendering.py against
  static/description/index.html and fails the build if any of the three
  patterns the App Store sanitiser breaks reappears: background
  declarations, white text, or styled <a> tags. The rules are
  documented in a header comment inside index.html; this linter enforces
  them so the listing cannot regress without somebody noticing.
* Add the regenerated banner image at the top so the GitHub landing
  page leads with the same headline as the marketplace listing.

* Architecture diagram redrawn to show both Telegram and WhatsApp
  channels, the new throttle / dedup / preflight flow, and all five
  models that actually exist on disk (was Telegram-only and missed
  link.token + delivery.seen).

* Quickstart rewritten for the current setup. Telegram secret is now
  auto-generated rather than user-supplied (was wrong); WhatsApp setup
  steps added with the mandatory App Secret called out; LLM provider
  table updated with the actual default models from services/llm.py
  (was claude-opus-4-5, llama3-70b-8192 -- neither is correct);
  optional throttling knobs documented.

* /link section rewritten to describe the two-step CSRF-protected
  handshake added in 17.0.8.

* Security section expanded to reflect every fix shipped since the
  public audit: preflight resolve-before-stage, CSRF protection, hijack
  defence, sudo_env trust boundary, rate limit + bounded pool,
  idempotency, token scrub, CI security scanning. Disclosure paragraph
  now points at GitHub Security Advisories and SECURITY.md instead of
  a bare email address.

* Roadmap replaced with current status -- 5 most-recent shipped versions
  with one-line themes plus three planned items (Odoo 18, admin views,
  OCA submission). Was last updated at 17.0.7; we're now on 17.0.11.

* LLM providers section deduplicated -- the table now lives in the
  Quickstart with the actual defaults; this section just notes the
  no-extra-dependencies design and the Ollama local option.

* Sponsor section expanded into Sponsor & feedback with the four links
  that also appear in the in-Odoo Settings panel.
Replaces the bare list-and-form pair that shipped historically with a
proper admin dashboard. The post-install operator experience now matches
the production-ready security model.

Identity model
--------------

Three new live-computed fields on odoopilot.identity, populated from the
audit table with a 7-day sliding window:

  * last_activity     -- timestamp of the most recent audit row
  * message_count_7d  -- count of audit rows in the window
  * success_rate_7d   -- % that succeeded

Implementation uses two read_group calls per recordset (no N+1) and
compute_sudo=True so the system-only audit table reads through cleanly.
A new action_view_audit method on the identity returns an act_window
that opens the audit log filtered to (user_id, channel) for that
identity, used by both the form-header button and a stat-button.

Linked Users view (renamed from "User Identities")
--------------------------------------------------

  * New columns: last_activity, message_count_7d, success_rate_7d
  * Decoration: green when active in window; muted when never used or
    inactive
  * Search filters: Active in last 7 days, Linked but never used,
    Telegram, WhatsApp, Inactive (unlinked)
  * Group-by: User, Channel, Language
  * Form view: smart-button stat tiles (msgs / success %), header
    "View activity" button, web_ribbon for inactive identities
  * Default search: active only

Audit Log view
--------------

  * Decoration-danger for failures; inline error_message column so
    trouble is visible without drilling into the form
  * Search filters: Failures only, Successes only, Write actions, Read
    actions, Telegram, WhatsApp, Today, Last 7 days
  * Group-by: User, Tool, Channel, Outcome, Day
  * Default open: Last 7 days, grouped by Day -- the most common
    operator question is "what happened recently?"
  * Form view: tool name as title, user/channel/timestamp as subtitle,
    notebook splitting result and JSON args into separate tabs

Tests
-----

  * New tests/test_admin_views.py with 7 tests covering: empty-state
    behaviour, window cutoff at 7 days, success-rate rounding, channel
    isolation, user isolation, action_view_audit payload.

The two existing CI scanners (bandit, semgrep) and the listing linter
remain green.
Companion to the new 18.0 branch (Alpha):

* .github/workflows/ci.yml now triggers on push to *.0 branches so the
  full check set runs on the 18.0 branch (and any future *.0 branches)
  on direct push.
* SECURITY.md supported-versions table updated: 18.0 listed as Alpha
  preview, 17.0 stays Supported.
* CHANGELOG header notes the branch split (17.0.x ships from 17.0,
  18.0.x ships from 18.0).

No code changes. Operators on 17 don't need to do anything.
A motivated user on a linked Telegram or WhatsApp chat could previously
persuade the bot to:

* Disclose its system prompt, tool definitions or conversation history
* Ignore its rules and roleplay as something else (DAN, dev mode, etc.)
* Burn the operator's LLM API budget writing Python code or telling
  jokes

Two layers of defence ship in this release.

services/scope_guard.py - pre-LLM regex filter
----------------------------------------------

Runs on every inbound user message before the LLM is called. Catches
the obvious extraction / jailbreak / off-topic patterns and short-
circuits with a fixed refusal:

> "I'm OdooPilot - I can only help with your Odoo data and actions
> (tasks, leaves, sales, CRM, inventory, etc.). For anything else,
> please use a different tool."

Patterns target prompt extraction, tool enumeration, memory dumps,
classic 'ignore previous instructions' jailbreaks, role hijacks
(act as / you are now / DAN / developer mode), delimiter injection
(<system>, <|im_start|>), code generation, creative content and
general knowledge questions like 'what's the weather'.

The filter is intentionally narrow: false positives on legitimate Odoo
questions defeat the product. 22 representative employee queries are
pinned in tests as MUST-pass-through; 32 attack strings are pinned as
MUST-block. Patterns were tuned against this corpus until both
directions reach 100%.

Operators can disable via Settings -> Technical -> System Parameters
by setting odoopilot.scope_guard_enabled = False. On by default.

Hardened SYSTEM_PROMPT in services/agent.py
-------------------------------------------

The prompt is the second line of defence and now explicitly spells out
what the bot does NOT do, the one-sentence refusal format, and the
trust boundary (only this system message contains instructions; user
messages / tool results / Odoo record content are untrusted data).

Audit visibility
----------------

Blocked attempts write an odoopilot.audit row with
tool_name='scope_guard_block', success=False, and the matching
pattern's reason in error_message. Operators can filter the Audit Log
by Failures or by tool name to see attempted abuse.

Tests
-----

tests/test_scope_guard.py with 7 test classes:
* TestLegitimateOdooQueriesPassThrough -- 22 queries
* TestPromptExtractionBlocked
* TestMemoryContextExtractionBlocked
* TestClassicJailbreaksBlocked
* TestDelimiterInjectionBlocked
* TestOffTopicComputeBlocked (code-gen / creative / off-topic)
* TestEmptyAndEdgeCases

CI green: ruff format + check, bandit (0 medium/high), semgrep
(0 blocking), listing renderable, all XML well-formed.
Six new tools widen the bot's audience to anyone in the company who
needs a one-tap-from-chat workflow. Tool count: 13 -> 19.

Read (1)
* find_partner -- contact lookup by name / email / phone substring
  in a single OR-domain search.

Write (5, all flow through preflight + nonce + audit pipeline)
* clock_in / clock_out -- hr.attendance for field staff and shift
  workers. Preflight rejects double-clock-in and clock-out-while-
  not-clocked-in. Execute path re-checks at run time.
* submit_expense -- hr.expense draft creation. Stays in draft on
  purpose: auto-submitting from chat would skip a deliberate human
  checkpoint in the Expenses module.
* submit_timesheet -- account.analytic.line entry against a project
  and optional task. Hours validated to [0, 24]. Resolved
  display_name in the confirmation, not the LLM's argument string.
* create_calendar_event -- calendar.event with the linked user as
  organizer. Accepts YYYY-MM-DD HH:MM or full ISO; the LLM
  converts relative phrases like "tomorrow at 10am" before calling.

All wired into TOOL_DEFINITIONS, WRITE_TOOLS, the execute_tool
dispatch map, preflight_write, and the _fmt_confirmation fallback.

Tests
-----

tests/test_employee_tools.py with 5 test classes:
* TestToolRegistryHygiene -- catches drift between TOOL_DEFINITIONS,
  WRITE_TOOLS, and the dispatch map (the four-way registry that
  would otherwise let the LLM call a tool that crashes at execute
  time).
* TestFindPartner -- finds by name / email / phone; empty / no-match
  return friendly messages.
* TestSubmitExpensePreflight -- short description, zero / negative /
  non-numeric amount.
* TestSubmitTimesheetPreflight -- zero hours, > 24 hours, short
  project name.
* TestCreateCalendarEventPreflight -- short name, missing start,
  malformed datetime, negative duration; accepts valid input.
* TestClockInPreflight -- friendly error when hr.attendance is not
  installed.

README roadmap updated to list shipped 17.0.12 / 17.0.13 / 18.0.1 /
18.0.2 and the upcoming voice-messages workstream.

CI green: ruff format + check, bandit (0 medium/high), semgrep
(0 blocking), listing renderable, all XML well-formed.
…next sprint

After 13 releases in 9 days the threat model has evolved -- scope guard,
6 new tools, admin views, throttle, dedup table all shipped since the
last audit. Adding 'internal security audit' as the explicit gate
before voice work begins, and elevating voice from a parenthetical to
the next named sprint.

No code change; README only.
The fourth security audit since the public Reddit one. Two High
findings, two Medium, one hygiene item. None is independently
exploitable today against a default-configured install; closing them
as defence-in-depth before the upcoming voice-messages work expands
the attack surface.

High - Scope guard hardened against Unicode and foreign-language bypasses
-------------------------------------------------------------------------

The pre-LLM regex filter was ASCII-English only, so Cyrillic
homoglyphs ("sуstem prompt"), zero-width-padded keywords, fullwidth
Latin ("Write Python"), and any non-English jailbreak (FR / ES / DE
/ PT / AR) bypassed it. The SYSTEM_PROMPT second-line defence
already refused these, but every bypass cost an LLM call.

Now: input runs through scope_guard._normalise (NFKC + strip
zero-width / bidi-override + Cyrillic+Greek homoglyph map) before
matching. Plus 16 new patterns for the top-5 jailbreaks in five
additional languages.

22 representative legit queries still pass, 32 original attacks still
block, plus 22 new bypass cases now block. Module docstring rewritten
to be honest about what's defended ("best-effort cost-saving filter")
and what isn't (multi-message split, Base64, novel languages).

Trojan Source compliance: invisible-character constants are now
declared by Unicode codepoint (str.maketrans({0x200B: None, ...}))
rather than as raw chars in source -- the file no longer carries the
chars it filters. Bandit B613 clean.

High - submit_expense / submit_timesheet rebind employee_id to env.uid
----------------------------------------------------------------------

The two new write tools shipped in 17.0.14 trusted whatever
employee_id was in the staged args. Today the only writer of those
args is the agent loop after preflight_write (which pins env.uid's
own employee), but a future code path that stages writes with a
different shape would let the executor write to another employee's
record. Defence-in-depth: re-resolve env.uid's hr.employee at execute
time, ignore any mismatched staged value, log a WARNING.

Same pattern as mark_task_done's existing ownership re-check.

Medium - find_partner limit capped at 25
----------------------------------------

The LLM could pass any limit value; record rules filter what the user
can read but a chat-mediated address-book scrape was one
find_partner(name='%', limit=999999) call away. New constant
_FIND_PARTNER_MAX_LIMIT = 25 caps the value.

Medium - RateLimiter._buckets opportunistic GC
----------------------------------------------

Dict was pruning timestamps within a bucket but never deleting empty
buckets, so it grew by one entry per unique (channel, chat_id) ever
seen. Bounded in practice by the number of real linked chats but a
slow leak under churn. New: opportunistic sweep every 256 calls drops
keys whose bucket is empty after pruning.

Hygiene - assert -> RuntimeError
--------------------------------

assert _limiter is not None / assert _pool is not None would be
optimised out under python -O. Replaced with explicit if-raise
branches.

Tests
-----

* test_scope_guard.py: TestUnicodeBypasses (3 cases),
  TestForeignLanguageJailbreaks (16 cases across FR/ES/DE/PT/AR)
* test_employee_tools.py: TestFindPartnerLimitCap (huge / negative /
  non-numeric limit handled), TestEmployeeIdRebinding (spoofed
  employee_id ignored on submit_expense and submit_timesheet)
* test_security.py: TestRateLimiterBucketGC (sweep shrinks the dict
  after a churn batch)

CI green: ruff format + check, bandit (0 medium/high), semgrep
(0 blocking), listing renderable, all XML well-formed.
Operators ask "will this handle my team?" -- this section answers it
concretely. Three deliverables:

* A by-team-size config table (20 / 100 / 300 / 1000 / 5000+
  employees) with the specific values to set for worker_pool_size,
  Odoo --workers, and the LLM provider tier needed.

* An honest framing of where the bottleneck actually sits: not
  OdooPilot, not Odoo, but the LLM provider's rate limit and price.

* Provider-by-provider rate-limit table at the tiers most teams
  actually use (Groq free / OpenAI Tier 1 / Anthropic Tier 2-4 /
  Ollama).

Plus a "watch for" sublist (peak-hour 3-4x average, multi-worker
throttle fairness, LLM cost monitoring) and a 5-step self-test
recipe to run before the first real user logs in.

No code change. No version bump.
Update README + SECURITY.md across all branches to reflect that the
18.0 series is now live at apps.odoo.com/apps/modules/18.0/odoopilot.

Specifically:

* README install section: link both Odoo 17 and Odoo 18 listings
  separately (was 17 only).
* README status table: 18.0.5.0.0 Beta on App Store (was 18.0.2.0.0
  Alpha, GitHub only).
* README roadmap: 'Validate Odoo 18 install' moved from open to done.
  OCA submission promoted to next operator-side item.
* SECURITY.md supported-versions table: 18.0 Alpha (preview) -> 18.0
  Supported, with App Store URLs in a new column.

Mirror of the same docs that ship in 18.0.5.0.0 on the 18 branch.
No code change on this branch.
…loop

The biggest UX upgrade left for the on-the-go-employee persona.
Warehouse pickers, drivers, anyone whose hands aren't free to type
can now talk to the bot. Voice notes flow through the same agent
loop, scope guard, per-write nonce + audit pipeline as typed text.
The only thing different is one extra step at the front of the pipe.

How it works
------------

1. User holds-to-record a voice note in Telegram or WhatsApp.
2. Webhook downloads the audio (Telegram getFile + the file/bot URL,
   or WhatsApp graph.facebook.com/<media-id> two-step).
3. Audio is transcribed via the configured STT provider's
   Whisper-compatible /audio/transcriptions endpoint.
4. Transcript fed into OdooPilotAgent.handle_message as if typed.

Provider matrix
---------------

* groq (default) -- whisper-large-v3, free tier with generous limits.
* openai -- whisper-1, ~$0.006/min audio.

Anthropic doesn't ship STT; Ollama can run whisper.cpp locally but
that's out of scope for v1.

Configuration (opt-in)
----------------------

Settings -> OdooPilot -> Voice messages. Five new fields:

* odoopilot.voice_enabled               -- master flag
* odoopilot.stt_provider                -- groq / openai
* odoopilot.stt_api_key                 -- separate from llm_api_key
* odoopilot.stt_model                   -- optional override
* odoopilot.voice_max_duration_seconds  -- default 60s

Voice is OFF by default. Auto-deriving from the LLM config would
silently route audio to a third party an Anthropic/Ollama operator
didn't pick.

Failure modes (all surfaced to the user, never silent drops)
-----------------------------------------------------------

* Voice not configured -> "Voice messages are not enabled..."
* Audio over duration cap -> "Voice message too long..."
  (cap checked from platform-reported duration BEFORE download)
* Download fails or oversized (cap 25 MB) -> "Sorry, I couldn't
  download that voice message..."
* Empty / silent / unintelligible transcript -> "I couldn't make
  out any words..."
* STT 5xx / quota -> "Sorry, I couldn't transcribe that voice
  message right now..." (key scrubbed from logs)

Security properties carried over
--------------------------------

* Scope guard runs on the transcript, not the audio bytes -- so
  "ignore previous instructions" spoken or typed is the same.
* API key scrub mirrors the bot-token scrub in services/telegram.py.
* No new authorization surface: the linked user is still resolved
  from chat_id; voice cannot bypass record-rule scoping.

Files
-----

* services/stt.py (new) -- STTClient w/ groq + openai backends
* services/telegram.py -- download_voice(file_id) w/ size cap, MIME
  inference
* services/whatsapp.py -- download_media(media_id), two-step Meta
  Graph API audio fetch
* controllers/main.py -- _stt_client_or_none, _voice_too_long
  helpers; _transcribe_telegram_voice / _transcribe_whatsapp_voice
  controller methods; both dispatchers now route audio messages
  through the new path
* models/res_config_settings.py -- five new fields
* views/res_config_settings_views.xml -- new "Voice messages"
  setting box, conditional on master flag
* tests/test_voice.py (new) -- 5 test classes, 14 cases:
  STTClient construction, input validation, key scrubbing,
  duration cap helper, _stt_client_or_none config gate

CI green: ruff format + check, bandit (0 medium/high), semgrep
(0 blocking), listing renderable, all XML well-formed.
Voice support shipped in 17.0.16.0.0 / 18.0.6.0.0 but the only place
it appeared in user-facing docs was a one-line bullet in README's
'What it does' section. Operators browsing the App Store listing,
the manifest summary, or the architecture diagram had no way to
discover the feature. Fixing that here.

App Store listing (static/description/index.html)
-------------------------------------------------

* Hero badges: new "🎙 Voice messages" pill alongside the existing
  channel / language badges.
* Hero subtitle: changed "by chatting with a bot" to "by typing or
  speaking to a bot" -- one-word edit, big positioning lift.
* New dedicated "Voice messages -- speak instead of typing" section
  between the day-in-the-life and why-OdooPilot-wins blocks. Three
  bordered cards (who it's for / how it works / what it costs) plus
  a security-properties callout that pins "scope guard runs on the
  transcript, not the audio bytes".

Manifest description
--------------------

* New keyword group: "Odoo voice messages, voice-to-Odoo, speak to
  Odoo, Whisper Odoo, Odoo STT" (text-search hits on the App Store).
* New Key Features bullet between Dual-channel and Multi-language,
  describing voice support, the provider matrix, and the duration
  cap.

README
------

* Architecture diagram redrawn to show the STT path: text messages
  go straight to the agent; voice messages route through the new
  STT Client box first, then converge into the same agent loop.
* New "Voice messages (optional)" section in Quickstart's
  Configuration block, mirroring the in-Odoo settings UI.
* Throttling-knobs table now includes
  odoopilot.voice_max_duration_seconds.
* Sizing & capacity section adds a "Voice messages -- capacity notes"
  subsection: STT cost per provider, the two safeguards (duration
  cap + 25 MB file cap), and a worked example (300 employees with
  10% voice adoption costs ~$1/day extra on OpenAI, $0 on Groq).
* Per-chat rate limit description corrected: "voice + text combined."

No code change. No version bump. The App Store will pick up the
new listing on its next refresh cycle (typically 24h).
No runtime behaviour change. Brings the codebase up to OCA-quality
standards so the actual upstream PR is mostly mechanical when it
lands. Tracked in the new CONTRIBUTING-OCA.md.

Module structure
----------------

* odoopilot/readme/ rewritten as 8 reStructuredText files per OCA's
  oca-gen-addon-readme template: DESCRIPTION, INSTALL, CONFIGURE,
  USAGE, ROADMAP, CONTRIBUTORS, CREDITS, MAINTAINERS. Previous
  Markdown files removed.
* New CONTRIBUTING-OCA.md at repo root documenting what's done and
  what's pending for the actual PR.

Manifest
--------

* New maintainers: ["arunrajiah"] field (OCA-required).
* author updated to "arunrajiah, Odoo Community Association (OCA)"
  per OCA convention (preempts pylint-odoo C8101).
* Removed redundant installable: True / auto_install: False (both
  at default values; flagged by C8116).
* description field kept with a # noqa: C8103 comment -- it is
  deprecated per OCA but the Odoo App Store search still indexes it.

Code style (pylint-odoo: 9.90 -> 10.00/10)
------------------------------------------

* W8161 prefer-env-translation: all _() calls in
  res_config_settings.py rewritten as self.env._(...).
* W8301 translation-not-lazy: % arg interpolation in translation
  calls switched to lazy positional args: self.env._("...", arg).
* W8303 translation-fstring-interpolation: two f-strings inside _()
  calls rewritten with lazy substitution.
* W8113 attribute-string-redundant: 6 redundant string=... field
  parameters dropped.
* W8138 except-pass: services/agent.py:_audit now logs at WARNING
  with exc_info=True instead of bare pass.

CI green: ruff format + check, pylint-odoo 10.00/10, bandit (0 M/H),
semgrep (0 blocking), listing renderable, all XML well-formed.
…users

A chatbot icon appears in the Odoo systray when the operator enables
it in Settings. Click -> a panel opens inside the Odoo UI with the
conversation history and an input field. Same agent loop, same
scope guard, same per-write nonce, same audit log as Telegram and
WhatsApp.

Why a third channel
-------------------

Telegram + WhatsApp cover the off-desk audience -- field sales,
warehouse, drivers. But desk users (sales reps doing pipeline,
accountants doing reconciliation, ops users running POs) live in
the Odoo browser tab all day and don't want to switch to a phone
app for a quick "who owns this lead?" query. This widget puts the
bot a click away in the same window.

How it works
------------

* Backend: services/web_chat.py exposes a WebChatClient -- a buffer
  matching the same surface as TelegramClient. The agent loop runs
  unchanged; messages accumulate as JSON envelopes which the HTTP
  route returns as the response body.

* Routes: /odoopilot/web/config (master flag for the frontend) and
  /odoopilot/web/message (POST a message, get back the buffered
  reply). Both auth="user", json. Identity is request.env.user --
  no /link flow, no spoofable chat_id.

* Frontend: a small OWL component registered in the systray
  category. ~150 LOC of JS, a QWeb template, and an SCSS file
  scoped under .o-odoopilot-systray. Component renders nothing
  until /config says enabled=true. Messages render as t-esc never
  t-raw -- a malicious tool result cannot inject HTML into the
  page.

* Confirmation flow: Yes / No buttons inline in the panel; clicks
  POST 'confirm:yes:<nonce>' to the same endpoint, routed by
  _handle_web_confirmation through the same nonce-verification
  logic the Telegram / WhatsApp paths use.

Configuration
-------------

Single boolean field: odoopilot.web_chat_enabled. Off by default --
operators who deployed Telegram / WhatsApp only might not want a
second surface, and turning it off stops the systray icon from
rendering on the next page reload.

Same per-(channel, chat_id) rate limit applies; web messages share
the budget with Telegram and WhatsApp messages from the same Odoo
user (channel='web', chat_id=str(user.id)).

What's NOT supported on web
---------------------------

Voice messages and file uploads. Streaming replies. All three are
on the messaging channels (Telegram / WhatsApp); the web channel
is text-only sync POST/response by design -- websockets would need
Odoo's longpolling worker which is a separate operator deployment.

Tests
-----

11 cases in tests/test_web_chat.py:

* WebChatClient buffer matches TelegramClient surface, calls
  accumulate as JSON-serialisable envelopes, reply_markup kwarg
  silently ignored, answer_callback_query is a safe noop.
* _handle_web_confirmation cancels on 'no', rejects bad nonces,
  executes on 'yes' with correct nonce (mocked execute_confirmed),
  replies 'nothing to confirm' when no pending, no-ops on
  malformed payloads.
* Web sessions don't collide with Telegram / WhatsApp sessions
  even when chat_id values overlap -- channel-keyed isolation
  still works.

CI green: ruff format + check, pylint-odoo 10.00/10, bandit (0 M/H),
semgrep (0 blocking), listing renderable, all XML well-formed
(including the new QWeb template).

Docs updated: README "What it does" + new "In-Odoo web chat widget"
config block, App Store listing badge, readme/DESCRIPTION.rst,
readme/CONFIGURE.rst, readme/USAGE.rst.
…ywhere

No code-path behaviour change. Adds the support contact across every
operator-facing surface:

* __manifest__.py: new 'support' field. The Odoo App Store renders
  this as a 'Contact' link in the right-hand sidebar of the module
  detail page.
* README.md: new top-level Support section + 'General support' line
  in the Sponsor & Feedback list.
* App Store listing (static/description/index.html): the right-hand
  Resources card gains a bold 'Email support' line and is renamed
  'Community & support'.
* Settings community panel: new 5th card with mailto: link
  pre-filling 'OdooPilot support' as the subject.
* readme/MAINTAINERS.rst: lists the email + routes public questions
  to GitHub Issues / Discussions and security disclosures to GitHub
  Security Advisories.

Verified along with this change:

* App Store cache state: 17 listing at 17.0.15.0.0 (3 versions
  behind), 18 listing at 18.0.6.0.0 (2 versions behind). Both
  catching up; Voice + Web Chat features will land in the next
  refresh cycle.
* The App Store's per-module detail page does NOT display
  banner.png (only the icon). The banner ships with the addon and
  is used for category browse / grid views; nothing wrong on our
  side.
Adds the audit-pattern sponsor pitch on top of the existing links section: a procurement anchor (a $50/mo team sponsorship is roughly the cost of a single Odoo Enterprise seat), four concrete roadmap items sponsorship currently funds (write-tool coverage in Invoices/Inventory/Purchase, OCA submission, Redis-backed rate limiter, faster issue turnaround), and a three-tier ladder ($10/$50/$200). The existing Sponsor/Feedback/Bug/Security/Email links are preserved.

Docs-only change. No code, dependencies, or behavior touched.
@github-actions github-actions Bot added the docs Documentation label May 15, 2026
@arunrajiah arunrajiah force-pushed the main branch 2 times, most recently from 5558289 to 3b0119e Compare May 16, 2026 02:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant