-
-
Notifications
You must be signed in to change notification settings - Fork 27
Teams and Mattermost Developer Guide
Since 3.1.0 Β· Contributed by Andrew Turbay (@turbay-a) in PR #166 Β· User pages: Microsoft Teams Β· Mattermost
How the Microsoft Teams and Mattermost channels work underneath, and how a chat customer is asked for their rating in the chat. It also records what changed when the PR was merged, and why β each change in the shape the PR did X, the problem was Y, now it does Z β because most of them guard against something that would only show up on a real install. The rules themselves are marked in the code with TRAP: comments (Code traps); git grep -n "TRAP:" -- includes/messaging includes/csat.php lists them.
PR #166 is Andrew's second channel after Telegram, and it shows: two complete providers on the shared MessagingProvider contract, a Teams app package built in code (no zip extension needed), real rating buttons on three channels, setup guides with troubleshooting tables, and every new string translated into fourteen languages. Kept exactly as he built it:
-
The provider design β
parseInbound()/sendMessage()/sendMedia()/downloadMedia()/testConnection()per channel, registered inmessagingProvider(). -
Teams: one-to-one only, group chats ignored; images out as attachments through the signed
media.phplink; the Azure AD client-credentials token cached for the request only and never written to disk. -
The Teams app package (
TeamsPackage.php): a stored zip written by hand, files at the root,manifest.jsonfrom the saved App ID, generated icons, behind the messaging capability andanalystCanAccessChannel(). - Mattermost: the support-channel filter, the bot account, files uploaded then attached (any type), and HMAC-signed rating buttons β a forged press fails.
-
Ratings:
sendRatingRequest()on the provider contract with a plain-text default, Telegram inline buttons withanswerCallbackQuery(), Teams Adaptive Card buttons,csatRecordRating()that records once (WHERE rating IS NULL), and a press checked against the chat it was sent to. -
channelHasServiceWindow()andnormaliseChannelIdentifier()extended for both β the checklist the Telegram guide asks for.
| File | Role |
|---|---|
includes/messaging/TeamsProvider.php |
Bot Framework: verify, parse, send, media, token |
includes/messaging/MattermostProvider.php |
Outgoing webhook in, REST v4 out, threaded |
includes/messaging/TeamsPackage.php, api/messaging/teams_package.php
|
The Teams app zip |
includes/messaging/messaging.php |
messagingReplyAddress() + MESSAGING_THREADED_CHANNELS, provider registry |
includes/messaging/ingest.php |
Rating presses and digits; reply address stored; per-channel threading |
includes/csat.php |
Asking in the chat; matching a reply to a request |
api/messaging/save_channel.php, get_channels.php, tickets/settings/index.php
|
Setup |
tests/messaging-teams-mattermost.php |
37 checks |
On a phone-like channel (WhatsApp, Telegram, web chat) you answer the sender. On a threaded one you answer into the conversation, which ingest stores as the inbound row's to_recipients:
| Channel |
to_recipients (the reply address) |
|---|---|
| Slack |
C08HELP:1719500000.000100 β channel + thread |
| Teams |
https://smba.trafficmanager.net/emea/|a:1x2yβ¦ β serviceUrl + conversation |
| Mattermost |
<channelId>:<postId> β the post to thread under |
messagingReplyAddress($channelType, $row) is that rule, and the composer (send_message.php), the in-chat rating request and its thank-you all use it. Before: the rule was an if ($channelType === 'slack') in send_message.php, and the PR's CSAT code copied it β so it was in two places, and Teams and Mattermost would have needed a third.
The PR checked the signature, issuer, audience and expiry with a hand-written JWT and RSA-key decoder. Now:
| The PR | The problem | Now | |
|---|---|---|---|
| Signature | Hand-built DER/PEM from the JWK, openssl_verify()
|
A second, untested copy of security-critical code that the codebase already has | The vendored firebase/php-jwt (JWK::parseKeySet(), JWT::decode()), as includes/oidc.php uses for SSO; 5-minute leeway |
serviceurl claim |
Not checked | Bot Framework requires it. Replies are POSTed to the serviceUrl with the bot's access token, so a message that could name its own serviceUrl could collect the token | Must equal the activity's serviceUrl
|
| serviceUrl host | Any https | Belt and braces for the above | Microsoft Bot Framework hosts only (SERVICE_HOSTS) |
| Tenant | Not checked | Anyone in any Microsoft 365 organisation who found the bot could open tickets |
conversation.tenantId must be the channel's Tenant ID |
The PR stored the latest serviceUrl on the channel (messaging_channels.channel_ref). The serviceUrl is regional and per conversation, so the next person to write from another region would have moved everyone's replies there. Now parseInbound() sets to = serviceUrl|conversationId, it's stored on the message row like Slack's thread, and postActivity() splits it. Nothing is written to the channel.
The PR fetched an image's contentUrl with the bot's token, following redirects. A token must not follow a redirect to wherever it points β the same rule as the SSO review. Now: Microsoft hosts only (MEDIA_HOSTS), no redirects.
The PR replied to each customer by direct message from the bot. Mattermost's outgoing webhooks only fire for posts in public channels, so when the customer answered the DM β which is what anyone does β FreeITSM never saw it. The conversation stopped with no error anywhere. Andrew's own setup guide had considered threads and set them aside because the webhook doesn't say which thread a post is in.
Now Mattermost works like Slack: the reply goes in the thread under the customer's post, and their next post in that thread comes back through the same webhook. The webhook's missing thread id is handled by asking the API: rootOf() fetches the post and threads under its root_id (Mattermost refuses a root_id that is itself a reply). The trade-off β anyone in the support channel can read the thread β is Slack's too, and the user page says so.
Because replies are now posted in the support channel, the webhook hands them straight back. parseInbound() drops any post by the bot's own user (users/me, cached per request). Without it, every analyst reply would arrive as a message from "the customer".
The webhook token was compared with hash_equals(); the rating button's signature with ===. Both now use hash_equals().
findOpenChannelTicket() matches a conversation by sender. A Mattermost user id is the same in every configuration on one server β two companies' support channels on one Mattermost would have threaded a person's message into the other company's ticket. That's Telegram trap 4 again, so the (channel, sender) rule now covers Telegram, Mattermost and Teams. A new channel whose sender id is not unique per channel must be added there.
The PR, and the merge, made every Teams and Mattermost person a contact of their own (β¦@teams.local), even when FreeITSM already held them by email, so their chat tickets sat apart from their emailed ones, their history and their company. The contributor pointed out that the email is the better identity; Slack already worked that way. Now resolveDirectoryRequester() asks the platform (TeamsProvider::lookupUser(), Bot Framework's get conversation member, no Graph permission; MattermostProvider::lookupUser(), GET /users/{id}) and messagingKnownPersonByEmail(), shared with Slack, files the ticket under that person. Otherwise it's the channel's own contact, with the real name, healed if it was first created with only the raw id.
-
TRAP: Mattermost's email counts only when
email_verifiedis true. An unverified address is just text someone typed; trusting it would file a stranger's chats under whoever owns that address. -
Only the requester changed. Which ticket a message joins is still decided by the platform id (
findOpenChannelTicket()): an id never changes, an email can. - Teams uses
email, thenuserPrincipalNamewhen it's shaped like an address. The address comes from the tenant's own directory, andverifyWebhook()only admits that tenant. - Tested in
tests/messaging-teams-mattermost.php: a verified email matches, an unverified one doesn't, Teams matches, and a failed lookup still raises the ticket.
A customer who wrote in on WhatsApp, Telegram, Slack, Teams or Mattermost can be asked for their 1β5 rating in that chat: buttons on Telegram, Teams and Mattermost, "reply with a single digit" elsewhere.
| The PR | The problem | Now | |
|---|---|---|---|
| Switching it on | Always, for any ticket whose latest message was a chat | Every existing install that emails surveys would start messaging its WhatsApp and Slack customers the day it upgraded |
Tickets β Settings β CSAT β Ask chat customers in their chat (csat_in_channel): off after an upgrade (db_verify seeds 0), on for a new install (freeitsm.sql seeds 1 first) |
| Sent regardless | Outside the 24-hour window WhatsApp only accepts a pre-approved template β the send fails | Only while channelWindowOpen(); otherwise the email survey |
|
| A failed send | The row was inserted, then the send threw | An orphan row β which sendCsatSurvey()'s own rule forbids β and with one survey per ticket on, the customer was never asked again |
The row is deleted and the email survey is tried |
| Which request a reply answers | Found through emails: the latest unanswered survey on any ticket this chat ever wrote on |
Also matched emailed surveys, and requests months old |
ticket_csat_responses.channel_id + channel_from record where a request went; only those are matched |
| A bare digit | Any 1β5 with a pending request became a rating |
"Which floor?" "3" β an answer to an analyst β vanished into a survey, as did a 2 weeks later |
A rating only if the request is in this chat, at most 7 days old, and the customer has written nothing else in the chat since |
| The thank-you | Sent to the sender | On Slack, Teams and Mattermost that's a DM, not the conversation | messagingReplyAddress() |
The button press is still checked against the chat it was sent to (csatResponseBelongsToChat()), and csatRecordRating() still records once.
Telegram, after testing on a live bot (3.1.0):
-
The buttons never responded at first.
setWebhookasked formessageandedited_messageonly, so Telegram never sent a press. It now asks forTelegramProvider::TELEGRAM_UPDATE_TYPES, which includescallback_query. A bot connected earlier needs Connect pressed again, and Test says so. See Telegram β Developer Guide. -
The answer replaces the buttons.
answerCallbackQuery()shows a notice that fades in seconds, and the five buttons stayed under the question, where a second tap silently did nothing. NowcloseRatingRequest()callseditMessageText: the question is kept, "You rated this 4 out of 5 - thank you." goes under it, and with noreply_markupthe keyboard goes. It shows the rating that stands (csatStoredRating()), so a second tap on an old copy of the question shows the first answer, not the one just tapped. An unanswered survey, or one from another chat, keeps its buttons. -
Tested without Telegram.
MessagingProvider::$testTransportstands in for the network (tests only).tests/messaging-teams-mattermost.phpsends a press throughingestInboundMessage()and checks theeditMessageTextcall.
-
channel_refon save. The PR wrotechannel_refon every save,NULLfor anything but Mattermost. Slack keeps its workspace id there; a later provider might too. Now only Mattermost's save writes it. -
Doc comments. New functions were inserted between an existing function and its doc comment in
csat.php(sendCsatSurvey()'s comment sat oncsatTicketChannel()) andTelegramProvider.php(requestContact()'s on the rating parser). Both are back where they belong. -
docs/. The two setup guides became the Microsoft Teams and Mattermost wiki pages; the help and settings link there.
php tests/messaging-teams-mattermost.php β 37 checks:
-
Teams, with tokens signed by a key made in the test (
TeamsProvider::$testKeys), nothing sent to Microsoft: accepted when right; refused for no header, another key, another audience, another issuer, an expired token, a serviceUrl the token didn't name, a non-Microsoft serviceUrl, another tenant. The reply address carries the conversation's serviceUrl; group chats are ignored; a button press is read. - Mattermost: the webhook token, an empty token, posts from another channel and from the bot itself, the channel:post address, a signed press accepted and an altered one refused.
- Ratings, in a transaction that is always rolled back: a digit just after a request; not from another chat; not after the customer has written again; not after a week; an emailed survey never answered from a chat; recorded once; with the setting off, the email survey.
The real ingest path was also driven in a rolled-back transaction with two Teams channels: a new ticket, threading into it, the second channel opening its own ticket, a digit with nothing pending kept as a message, and the reply address stored.
Not run: a live Teams tenant and a live Mattermost server. The first real use should be watched: the token exchange, an Adaptive Card button press, and Mattermost's response to a button callback.
See also: Microsoft Teams Β· Mattermost Β· Slack β Developer Guide Β· Telegram β Developer Guide Β· Code traps
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96