GroupMe Member Manager
An admin-only Google Apps Script bot for one GroupMe chat. Authorized users can text commands to add or remove members by phone number — GroupMe’s SMS-invite flow without ever leaving the chat.
Features
/add "Name" +15551234567— invites a member by phone number/remove +15551234567— removes a member the bot added, by phone/remove "Name"— removes a member by exact nickname (only if the name is unambiguous)/lookup +15551234567— checks whether the bot is tracking that number- Only the group’s creator or a listed admin can run commands; everyone else is ignored (with a permission notice)
- Tracks which memberships it created so
/removecan look up the correct membership ID later - Waits on GroupMe’s async invite flow (up to ~24s) and confirms the person actually joined before saving the record
Setup
- Create a new Google Apps Script project and paste in
Code.gs. - Fill in
CONFIGat the top of the file:GROUPME_ACCESS_TOKEN— your GroupMe access tokenGROUPME_BOT_ID— the bot’s ID (create one at dev.groupme.com if you haven’t)ADMIN_IDS— optional. GroupMe user IDs allowed to run commands in addition to the group creator. Leave unused slots as empty strings.
- Deploy the project as a Web App:
- Execute as: Me
- Who has access: Anyone
- Choose
setupBotfrom the Apps Script function dropdown and click Run. - Open the execution log and copy the printed
?key=...text. - Paste it after your Web App URL, e.g.
https://script.google.com/macros/s/DEPLOYMENT_ID/exec?key=PASTE_KEY_HERE. - Set that complete URL as the callback URL in your bot’s GroupMe settings.
- Run
testBotMessage()once to confirm the bot can post to the chat.
Finding your group/bot/user IDs
If you don’t already know them:
listMyGroups()— logs every group you’re in asName | GROUP_IDlistGroupMembers()— logs current members asName | user: USER_ID | membership: MEMBERSHIP_ID(theUSER_IDvalues are what go inADMIN_IDS)
Both are run manually from the Apps Script editor, not via chat commands.
Usage
Only the group creator or a configured admin can issue commands. Anyone else gets a “you do not have permission” reply.
/add "Jane Doe" +15551234567
/remove +15551234567
/remove "Jane Doe"
/lookup +15551234567
Phone numbers accept plain 10-digit US numbers, 11-digit with leading 1, or +-prefixed international format (+<countrycode><number>, 11–15 digits).
Sending a bare /add or /remove with no arguments gets you a usage reminder.
How it works
Implementation notes
- Membership records are stored in Apps Script’s
PropertiesServiceas aphone → membershipIdmap, guarded byLockServiceto avoid races on concurrent commands. - Authorization checks the sender against
ADMIN_IDSfirst; if that misses, it falls back to a live lookup of the group’screator_user_id. /removeby phone only works for numbers the bot itself added — it has no membership ID for anyone else./removeby name works for anyone in the group, but only if the nickname is unique.- Adding a member is asynchronous on GroupMe’s end: the bot submits the invite, then polls
/members/results/{resultsId}every 2 seconds for up to 24 seconds, matching on the request’sguidto find the resulting membership ID. - The webhook endpoint checks a
?key=query param (set once insetupBot()) against incoming requests before processing anything, so random POSTs to the/execURL are ignored.
Limitations
- One bot instance manages one chat.
/remove "Name"fails safe if multiple members share that nickname — you’ll need to remove by phone instead.- If the 24-second invite window times out, the invite may have still gone through on GroupMe’s side even though the bot couldn’t confirm it — check
/lookuporlistGroupMembers()if unsure. ADMIN_IDShas 10 fixed slots; unused entries must stay empty strings rather than being removed.
Readme writtten by Claude
Code
Code.gs
javascript
/**
* GroupMe admin-only member manager for ONE chat.
*
* Admin commands:
* /add "Jane Doe" +15551234567
* /remove +15551234567
* /remove "Jane Doe"
*
* Before deploying, paste your own GroupMe values in CONFIG below.
*
* Then run setupBot() once. It creates the callback key automatically and
* writes the exact ?key=... part to copy after your /exec deployment URL.
*/
const CONFIG = {
GROUPME_ACCESS_TOKEN: 'PASTE_YOUR_GROUPME_ACCESS_TOKEN_HERE',
GROUPME_BOT_ID: 'PASTE_YOUR_GROUPME_BOT_ID_HERE',
// Optional GroupMe user IDs. Example with three admins:
// '1234567890', '2345678901', '3456789012'
// Leave unused slots as empty strings.
ADMIN_IDS: [
'', '', '', '', '', '', '', '', '', '',
],
};
const API_URL = 'https://api.groupme.com/v3';
const PROPERTIES = PropertiesService.getScriptProperties();
const RECORDS_KEY = 'managed_members';
const DEBUG = false;
const ADMINS = new Set(CONFIG.ADMIN_IDS.filter(Boolean).map(String));
function doGet() {
return ContentService.createTextOutput('GroupMe member manager is online.');
}
/**
* SETUP (run once after deploying as a Web App):
* 1. Choose setupBot from the Apps Script function dropdown and click Run.
* 2. Open the execution log and copy the printed ?key=... text.
* 3. Paste it after your Web App URL, for example:
* https://script.google.com/macros/s/DEPLOYMENT_ID/exec?key=PASTE_KEY_HERE
* 4. Set that complete URL as the callback URL in GroupMe bot settings.
*/
function setupBot() {
getAccessToken_();
getBotId_();
let callbackKey = PROPERTIES.getProperty('CALLBACK_SECRET');
if (!callbackKey) {
callbackKey = Utilities.getUuid().replace(/-/g, '') + Utilities.getUuid().replace(/-/g, '');
PROPERTIES.setProperty('CALLBACK_SECRET', callbackKey);
}
console.log(`COPY THIS AFTER YOUR /exec URL: ?key=${callbackKey}`);
}
/** Run manually once to verify GROUPME_BOT_ID and Apps Script authorization. */
function testBotMessage() {
postBotMessage_('Bot connection test successful.');
}
/** Run manually from Apps Script to print: Name | GroupMe user ID. */
function listGroupMembers() {
const groupId = getBotGroupId_();
const result = groupMeRequest_(`/groups/${groupId}`, 'get');
if (!result.ok) throw new Error(`Could not load members: ${result.error}`);
const members = result.data?.response?.members || result.data?.members || [];
console.log(members.map(member =>
`${member.nickname} | user: ${member.user_id} | membership: ${member.id || member.membership_id || 'unknown'}`
).join('\n'));
}
/** Run this first if you do not know the numeric GROUP_ID. */
function listMyGroups() {
const result = groupMeRequest_('/groups', 'get');
if (!result.ok) throw new Error(`Could not load groups: ${result.error}`);
const groups = result.data?.response || result.data || [];
console.log(groups.map(group => `${group.name} | ${group.id}`).join('\n'));
}
function doPost(event) {
try {
if (!event.parameter || event.parameter.key !== getRequiredProperty_('CALLBACK_SECRET')) return ok_();
const message = JSON.parse(event.postData.contents || '{}');
handleMessage_(message);
} catch (error) {
console.error(error);
}
return ok_();
}
function handleMessage_(message) {
if (handlePublicListAddon_(message)) return;
if (message.sender_type !== 'user' || message.system) return;
const groupId = String(message.group_id || '');
if (!groupId) return;
const command = parseCommand_(message.text);
if (!command) {
const text = String(message.text || '').trim().toLowerCase();
if (text === '/add' || text === '/remove') {
postBotMessage_('Usage:\n/add "Name" +15551234567\n/remove "Exact Alias"\n/lookup +15551234567');
}
return;
}
const senderId = String(message.user_id || message.sender_id || '');
if (!isAuthorized_(groupId, senderId)) {
postBotMessage_('You do not have permission to do this.');
return;
}
if (command.type === 'lookup') {
const membershipId = getMemberRecord_(command.phone);
postBotMessage_(membershipId
? `${command.phone} is managed by this bot. Membership ID: ${membershipId}`
: `${command.phone} is not managed by this bot.`);
return;
}
if (command.type === 'add') {
const result = addMember_(groupId, command.name, command.phone);
postBotMessage_(result.ok
? `Added ${command.name} to the chat.`
: `Could not add ${command.name}: ${result.error}`);
return;
}
const result = command.removeBy === 'phone'
? removeMemberByPhone_(groupId, command.value)
: removeMemberByName_(groupId, command.value);
postBotMessage_(result.ok
? `Removed ${command.value} from the chat.`
: `Could not remove ${command.value}: ${result.error}`);
}
function parseCommand_(text) {
const tokens = tokenize_(text);
const verb = tokens.shift()?.toLowerCase();
if (verb === '/remove') {
const value = tokens.join(' ').trim();
if (!value) return null;
const phone = normalizePhone_(value);
return phone
? { type: 'remove', removeBy: 'phone', value: phone }
: { type: 'remove', removeBy: 'name', value: value };
}
if (verb === '/lookup') {
const phone = normalizePhone_(tokens.join(' ').trim());
return phone ? { type: 'lookup', phone: phone } : null;
}
if (verb === '/add') {
const phone = normalizePhone_(tokens.pop());
const name = tokens.join(' ').trim();
return name && phone ? { type: 'add', name: name, phone: phone } : null;
}
return null;
}
function tokenize_(text) {
const tokens = [];
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
let match;
while ((match = pattern.exec(String(text || '').trim())) !== null) {
tokens.push(match[1] ?? match[2] ?? match[3]);
}
return tokens;
}
function normalizePhone_(value) {
const input = String(value || '').trim();
const digits = input.replace(/[^0-9]/g, '');
// Use +<country code> for non-U.S. numbers.
if (digits.length === 10) return `+1${digits}`;
if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
if (input.startsWith('+') && digits.length >= 11 && digits.length <= 15) return `+${digits}`;
return null;
}
function isAdmin_(userId) {
return ADMINS.has(String(userId));
}
function isAuthorized_(groupId, userId) {
if (isAdmin_(userId)) return true;
const group = groupMeRequest_(`/groups/${groupId}`, 'get');
const details = group.data?.response || group.data || {};
return group.ok && String(details.creator_user_id || '') === String(userId);
}
function getBotGroupId_() {
const result = groupMeRequest_('/bots', 'get');
if (!result.ok) throw new Error(`Could not load bot details: ${result.error}`);
const bots = result.data?.response || result.data || [];
const bot = bots.find(item => String(item.bot_id) === getBotId_());
if (!bot?.group_id) throw new Error('Configured GROUPME_BOT_ID was not found.');
return String(bot.group_id);
}
function addMember_(groupId, name, phone) {
if (getMemberRecord_(phone)) {
return { ok: false, error: 'that phone number was already added by this bot' };
}
const guid = Utilities.getUuid();
const result = groupMeRequest_(`/groups/${groupId}/members/add`, 'post', {
members: [{ nickname: name, phone_number: phone, guid: guid }],
});
if (!result.ok) return result;
// Save the membership ID. GroupMe needs that ID to remove the person later.
const response = result.data?.response || result.data || {};
const resultsId = response.results_id || response.id;
if (!resultsId) {
return { ok: false, error: 'GroupMe did not return a results ID for this request' };
}
const member = waitForMembership_(groupId, resultsId, guid);
const membershipId = member?.id || member?.membership_id;
if (!membershipId) {
return { ok: false, error: 'GroupMe accepted the request, but the invitation did not complete in time' };
}
saveMemberRecord_(phone, membershipId);
return { ok: true };
}
function removeMemberByPhone_(groupId, phone) {
const membershipId = getMemberRecord_(phone);
if (!membershipId) {
return { ok: false, error: 'person not found (the bot can remove only numbers it added)' };
}
const result = groupMeRequest_(`/groups/${groupId}/members/${membershipId}/remove`, 'post');
if (result.ok) deleteMemberRecord_(phone);
return result;
}
function removeMemberByName_(groupId, name) {
const group = groupMeRequest_(`/groups/${groupId}`, 'get');
if (!group.ok) return group;
const members = group.data?.response?.members || group.data?.members || [];
const wantedName = name.trim().toLowerCase();
const matches = members.filter(member => String(member.nickname || '').trim().toLowerCase() === wantedName);
if (matches.length === 0) return { ok: false, error: 'person not found' };
if (matches.length > 1) {
return { ok: false, error: 'more than one person has that name; remove by phone number instead' };
}
const membershipId = matches[0].id || matches[0].membership_id;
if (!membershipId) return { ok: false, error: 'membership ID not found' };
const result = groupMeRequest_(`/groups/${groupId}/members/${membershipId}/remove`, 'post');
if (result.ok) deleteMemberRecordById_(membershipId);
return result;
}
function waitForMembership_(groupId, resultsId, guid) {
// GroupMe's add endpoint is asynchronous; allow up to 24 seconds.
for (let attempt = 0; attempt < 12; attempt += 1) {
Utilities.sleep(2000);
const result = groupMeRequest_(`/groups/${groupId}/members/results/${resultsId}`, 'get');
if (!result.ok) continue;
if (DEBUG) console.log(JSON.stringify(result.data));
const response = result.data?.response || result.data || {};
const candidates = [response.members, response.results, response.response]
.find(Array.isArray) || [];
for (const item of candidates) {
const member = item.member || item;
if (member.guid === guid) return member;
}
}
return null;
}
function groupMeRequest_(path, method, payload) {
try {
const options = {
method: method,
headers: { 'X-Access-Token': getAccessToken_() },
muteHttpExceptions: true,
};
if (payload) {
options.contentType = 'application/json';
options.payload = JSON.stringify(payload);
}
const response = UrlFetchApp.fetch(`${API_URL}${path}`, options);
const status = response.getResponseCode();
const data = parseJson_(response.getContentText());
if (status >= 200 && status < 300) return { ok: true, data: data };
return {
ok: false,
error: data?.meta?.errors?.join(', ') || data?.response?.message || data?.message || `GroupMe returned ${status}`,
};
} catch (error) {
return { ok: false, error: error.message || 'Network request failed' };
}
}
function getMemberRecord_(phone) {
return getMemberRecords_()[phone] || null;
}
function saveMemberRecord_(phone, membershipId) {
updateMemberRecords_(records => { records[phone] = membershipId; });
}
function deleteMemberRecord_(phone) {
updateMemberRecords_(records => { delete records[phone]; });
}
function deleteMemberRecordById_(membershipId) {
updateMemberRecords_(records => {
Object.keys(records).forEach(phone => {
if (records[phone] === membershipId) delete records[phone];
});
});
}
function getMemberRecords_() {
return parseJson_(PROPERTIES.getProperty(RECORDS_KEY) || '{}') || {};
}
function updateMemberRecords_(change) {
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
const records = getMemberRecords_();
change(records);
PROPERTIES.setProperty(RECORDS_KEY, JSON.stringify(records));
} finally {
lock.releaseLock();
}
}
function postBotMessage_(text) {
const response = UrlFetchApp.fetch(`${API_URL}/bots/post`, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ bot_id: getBotId_(), text: text }),
muteHttpExceptions: true,
});
if (response.getResponseCode() >= 300) {
console.error(`Bot reply failed: ${response.getContentText()}`);
}
}
function getRequiredProperty_(name) {
const value = PROPERTIES.getProperty(name);
if (!value) throw new Error(`Missing Apps Script property: ${name}`);
return value;
}
function getAccessToken_() {
const token = CONFIG.GROUPME_ACCESS_TOKEN.trim();
if (!token || token.startsWith('PASTE_')) throw new Error('Set GROUPME_ACCESS_TOKEN in CONFIG.');
return token;
}
function getBotId_() {
const botId = CONFIG.GROUPME_BOT_ID.trim();
if (!botId || botId.startsWith('PASTE_')) throw new Error('Set GROUPME_BOT_ID in CONFIG.');
return botId;
}
function parseJson_(value) {
try { return JSON.parse(value); } catch (_) { return null; }
}
function ok_() {
return ContentService.createTextOutput('ok');
}
function handlePublicListAddon_(message) {
if (String(message.text || '').trim().toLowerCase() !== '/list') return false;
const result = groupMeRequest_(`/groups/${getTargetGroupId_()}`, 'get');
if (!result.ok) {
postBotMessage_(`Could not list members: ${result.error}`);
return true;
}
const group = result.data?.response || result.data || {};
const names = (group.members || [])
.map(member => String(member.nickname || member.name || 'Unknown member'))
.sort((a, b) => a.localeCompare(b));
postPublicList_(names);
return true;
}
function postPublicList_(names) {
let remaining = `Members (${names.length}):\n${names.join('\n')}`;
const maximumLength = 950;
while (remaining.length > maximumLength) {
let cut = remaining.lastIndexOf('\n', maximumLength);
if (cut < 1) cut = maximumLength;
postBotMessage_(remaining.slice(0, cut));
remaining = remaining.slice(cut).replace(/^\n/, '');
}
if (remaining) postBotMessage_(remaining);
}