Groupme #add #remove sms Workaround admins Private

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 /remove can 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

  1. Create a new Google Apps Script project and paste in Code.gs.
  2. Fill in CONFIG at the top of the file:
    • GROUPME_ACCESS_TOKEN — your GroupMe access token
    • GROUPME_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.
  3. Deploy the project as a Web App:
    • Execute as: Me
    • Who has access: Anyone
  4. Choose setupBot from the Apps Script function dropdown and click Run.
  5. Open the execution log and copy the printed ?key=... text.
  6. Paste it after your Web App URL, e.g. https://script.google.com/macros/s/DEPLOYMENT_ID/exec?key=PASTE_KEY_HERE.
  7. Set that complete URL as the callback URL in your bot’s GroupMe settings.
  8. 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 as Name | GROUP_ID
  • listGroupMembers() — logs current members as Name | user: USER_ID | membership: MEMBERSHIP_ID (the USER_ID values are what go in ADMIN_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 PropertiesService as a phone → membershipId map, guarded by LockService to avoid races on concurrent commands.
  • Authorization checks the sender against ADMIN_IDS first; if that misses, it falls back to a live lookup of the group’s creator_user_id.
  • /remove by phone only works for numbers the bot itself added — it has no membership ID for anyone else. /remove by 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’s guid to find the resulting membership ID.
  • The webhook endpoint checks a ?key= query param (set once in setupBot()) against incoming requests before processing anything, so random POSTs to the /exec URL 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 /lookup or listGroupMembers() if unsure.
  • ADMIN_IDS has 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);
}

UPDTAE /list now works
2 Likes