Skip to content

Teams and Mattermost Developer Guide

Ed Mozley edited this page Oct 4, 2026 · 3 revisions

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.


Thank you, Andrew

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 in messagingProvider().
  • Teams: one-to-one only, group chats ignored; images out as attachments through the signed media.php link; 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.json from the saved App ID, generated icons, behind the messaging capability and analystCanAccessChannel().
  • 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 with answerCallbackQuery(), Teams Adaptive Card buttons, csatRecordRating() that records once (WHERE rating IS NULL), and a press checked against the chat it was sent to.
  • channelHasServiceWindow() and normaliseChannelIdentifier() extended for both β€” the checklist the Telegram guide asks for.

Files

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

1. Where a reply goes β€” one rule

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.

2. Teams

Verifying a message

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 reply address is per conversation

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.

Images in

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.

3. Mattermost

Replies in the thread, not by DM

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.

The bot's own posts

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".

Secrets

The webhook token was compared with hash_equals(); the rating button's signature with ===. Both now use hash_equals().

4. Threading: per channel, for Teams and Mattermost too

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.

4Β½. Who raised it: matched by email (after the merge)

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_verified is 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, then userPrincipalName when it's shaped like an address. The address comes from the tenant's own directory, and verifyWebhook() 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.

5. Ratings asked in the chat

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)
WhatsApp 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. setWebhook asked for message and edited_message only, so Telegram never sent a press. It now asks for TelegramProvider::TELEGRAM_UPDATE_TYPES, which includes callback_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. Now closeRatingRequest() calls editMessageText: the question is kept, "You rated this 4 out of 5 - thank you." goes under it, and with no reply_markup the 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::$testTransport stands in for the network (tests only). tests/messaging-teams-mattermost.php sends a press through ingestInboundMessage() and checks the editMessageText call.

6. Smaller things

  • channel_ref on save. The PR wrote channel_ref on every save, NULL for 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 on csatTicketChannel()) and TelegramProvider.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.

How it was tested

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

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally